Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions docs/python/endpoint-style-guide.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
# Views

Before importing from the default Django REST Framework views, check to see if we
have a custom base view in [our base views](/kirovy/views/base_views.py) first.

They often have a lot of boilerplate, type hints, and custom request classes set up for you.

For example, use `KirovyApiView` instead of `rest_framework.views.APIView`.

# API Errors in API class helpers

Returning errors in the helper functions of your API endpoint can be annoying.
To avoid that annoyance, just raise one of the [view exceptions](kirovy/exceptions/view_exceptions.py)
To avoid that annoyance, just raise one of the [view exceptions](/kirovy/exceptions/view_exceptions.py)
or write your own that subclasses `KirovyValidationError`.

**Example where you annoy yourself with bubbling returns:**

```python
class MyView(APIView):
class MyView(KirovyApiView):
...
def helper(self, request: KirovyRequest) -> MyObject | KirovyResponse:
object_id = request.data.get("id")
Expand Down Expand Up @@ -36,7 +45,7 @@ class MyView(APIView):
**Example where you just raise the exception:**

```python
class MyView(APIView):
class MyView(KirovyApiView):
...
def helper(self, request: KirovyRequest) -> MyObject:
object_id = request.data.get("id")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Generated by Django 4.2.23 on 2026-01-28 04:46

from django.db import migrations, models
import uuid


class Migration(migrations.Migration):

dependencies = [
("kirovy", "0020_alter_cncmapfile_file_alter_cncmapimagefile_file_and_more"),
]

operations = [
migrations.RemoveIndex(
model_name="cncmapfile",
name="kirovy_cncm_cnc_map_a1e8af_idx",
),
migrations.RemoveIndex(
model_name="cncmapimagefile",
name="kirovy_cncm_cnc_map_078511_idx",
),
migrations.RemoveIndex(
model_name="cncmapimagefile",
name="kirovy_cncm_is_extr_0259c5_idx",
),
migrations.RemoveIndex(
model_name="cncmapimagefile",
name="kirovy_cncm_cnc_use_b93113_idx",
),
migrations.RemoveIndex(
model_name="cncmapimagefile",
name="kirovy_cncm_cnc_gam_241448_idx",
),
migrations.AddField(
model_name="cncmapfile",
name="ip_address",
field=models.CharField(blank=True, db_index=True, max_length=50, null=True),
),
migrations.AddField(
model_name="cncmapimagefile",
name="ip_address",
field=models.CharField(blank=True, db_index=True, max_length=50, null=True),
),
migrations.AddField(
model_name="mappreview",
name="ip_address",
field=models.CharField(blank=True, db_index=True, max_length=50, null=True),
),
migrations.AlterField(
model_name="cncfileextension",
name="id",
field=models.UUIDField(
db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
migrations.AlterField(
model_name="cncgame",
name="id",
field=models.UUIDField(
db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
migrations.AlterField(
model_name="cncmap",
name="id",
field=models.UUIDField(
db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
migrations.AlterField(
model_name="cncmap",
name="is_legacy",
field=models.BooleanField(
db_index=True, default=False, help_text="If true, this is an upload from the old cncnet database."
),
),
migrations.AlterField(
model_name="cncmap",
name="is_mapdb1_compatible",
field=models.BooleanField(db_index=True, default=False),
),
migrations.AlterField(
model_name="cncmap",
name="is_published",
field=models.BooleanField(
db_index=True, default=False, help_text="If true, this map will show up in normal searches and feeds."
),
),
migrations.AlterField(
model_name="cncmap",
name="is_temporary",
field=models.BooleanField(
db_index=True,
default=False,
help_text="If true, this will be deleted eventually. This flag is to support sharing in multiplayer lobbies.",
),
),
migrations.AlterField(
model_name="cncmapfile",
name="id",
field=models.UUIDField(
db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
migrations.AlterField(
model_name="cncmapimagefile",
name="id",
field=models.UUIDField(
db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
migrations.AlterField(
model_name="cncmapimagefile",
name="is_extracted",
field=models.BooleanField(db_index=True, default=False),
),
migrations.AlterField(
model_name="mapcategory",
name="id",
field=models.UUIDField(
db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
migrations.AlterField(
model_name="mappreview",
name="id",
field=models.UUIDField(
db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
]
3 changes: 2 additions & 1 deletion kirovy/models/cnc_base_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
class CncNetBaseModel(models.Model):
"""Base model for all cnc net models to inherit from."""

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, db_index=True)

created = models.DateTimeField(auto_now_add=True, null=True)
modified = models.DateTimeField(auto_now=True, null=True)
Expand All @@ -18,6 +18,7 @@ class CncNetBaseModel(models.Model):
on_delete=models.SET_NULL,
null=True,
related_name="modified_%(class)s_set",
db_index=True,
)
""":attr: The last user to modify this entry, if applicable."""

Expand Down
2 changes: 1 addition & 1 deletion kirovy/models/cnc_game.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ def __repr__(self) -> str:
class GameScopedUserOwnedModel(CncNetUserOwnedModel):
"""A user owned object that is specific to a game. e.g. a map or image."""

cnc_game = models.ForeignKey(CncGame, models.PROTECT, null=False, blank=False)
cnc_game = models.ForeignKey(CncGame, models.PROTECT, null=False, blank=False, db_index=True)

class Meta:
abstract = True
29 changes: 8 additions & 21 deletions kirovy/models/cnc_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class CncMap(GameScopedUserOwnedModel, Moderabile):
is_legacy = models.BooleanField(
default=False,
help_text="If true, this is an upload from the old cncnet database.",
db_index=True,
)
""":attr:
This will be set for all maps that we bulk upload from the legacy cncnet map database.
Expand All @@ -78,8 +79,7 @@ class CncMap(GameScopedUserOwnedModel, Moderabile):
""":attr: Tracks the original upload dates for legacy maps, for historical reasons."""

is_published = models.BooleanField(
default=False,
help_text="If true, this map will show up in normal searches and feeds.",
default=False, help_text="If true, this map will show up in normal searches and feeds.", db_index=True
)
""":attr:
Did the map maker set this map to be published? Published maps show up in normal search and feeds.
Expand All @@ -90,6 +90,7 @@ class CncMap(GameScopedUserOwnedModel, Moderabile):
default=False,
help_text="If true, this will be deleted eventually. "
"This flag is to support sharing in multiplayer lobbies.",
db_index=True,
)
""":attr:
Whether this map is temporary. We don't want to keep storing every map that is shared in a multiplayer lobby,
Expand All @@ -105,15 +106,10 @@ class CncMap(GameScopedUserOwnedModel, Moderabile):
)

categories = models.ManyToManyField(MapCategory)
parent = models.ForeignKey(
"CncMap",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
parent = models.ForeignKey("CncMap", on_delete=models.SET_NULL, null=True, blank=True, db_index=True)
"""If set, then this map is a child of ``parent``. Used to track edits of other peoples' maps."""

is_mapdb1_compatible = models.BooleanField(default=False)
is_mapdb1_compatible = models.BooleanField(default=False, db_index=True)
"""If true, then this map was uploaded by a legacy CnCNet client and is backwards compatible with map db 1.0.

This should never be set for maps uploaded via the web UI.
Expand Down Expand Up @@ -182,7 +178,7 @@ class CncMapFile(file_base.CncNetFileBaseModel):
height = models.IntegerField()
version = models.IntegerField(editable=False)

cnc_map = models.ForeignKey(CncMap, on_delete=models.CASCADE, null=False)
cnc_map = models.ForeignKey(CncMap, on_delete=models.CASCADE, null=False, db_index=True)

ALLOWED_EXTENSION_TYPES = {game_models.CncFileExtension.ExtensionTypes.MAP.value}

Expand All @@ -192,7 +188,6 @@ class Meta:
constraints = [
models.UniqueConstraint(fields=["cnc_map_id", "version"], name="unique_map_version"),
]
indexes = [models.Index(fields=["cnc_map"])]

def save(self, *args, **kwargs):
if not self.version:
Expand Down Expand Up @@ -234,7 +229,7 @@ class CncMapImageFile(file_base.CncNetFileBaseModel):
width = models.IntegerField()
height = models.IntegerField()

cnc_map = models.ForeignKey(CncMap, on_delete=models.CASCADE, null=False)
cnc_map = models.ForeignKey(CncMap, on_delete=models.CASCADE, null=False, db_index=True)

ALLOWED_EXTENSION_TYPES = {game_models.CncFileExtension.ExtensionTypes.IMAGE.value}

Expand All @@ -243,7 +238,7 @@ class CncMapImageFile(file_base.CncNetFileBaseModel):
file = models.ImageField(null=False, upload_to=file_base.default_generate_upload_to, max_length=2048)
"""The actual file this object represent."""

is_extracted = models.BooleanField(null=False, blank=False, default=False)
is_extracted = models.BooleanField(null=False, blank=False, default=False, db_index=True)
"""attr: If true, then this image was extracted from the uploaded map file, usually generated by FinalAlert.

This will always be false for games released after Yuri's Revenge because Generals and beyond do not pack the
Expand All @@ -257,14 +252,6 @@ class CncMapImageFile(file_base.CncNetFileBaseModel):
If there are ``order`` collisions, then we fallback to the creation date.
"""

class Meta:
indexes = [
models.Index(fields=["cnc_map"]),
models.Index(fields=["is_extracted"]),
models.Index(fields=["cnc_user_id"]),
models.Index(fields=["cnc_game_id"]),
]

def save(self, *args, **kwargs):
if not self.name:
self.name = self.cnc_map.map_name
Expand Down
2 changes: 1 addition & 1 deletion kirovy/models/cnc_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def create_or_update_from_cncnet(user_dto: CncnetUserInfo) -> "CncUser":
class CncNetUserOwnedModel(CncNetBaseModel):
"""A mixin model for any models that will be owned by a user."""

cnc_user = models.ForeignKey(CncUser, on_delete=models.PROTECT, null=True)
cnc_user = models.ForeignKey(CncUser, on_delete=models.PROTECT, null=True, db_index=True)
""":attr: The user that owns this object, if it has an owner."""

class Meta:
Expand Down
3 changes: 3 additions & 0 deletions kirovy/models/file_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ class Meta:
hash_sha1 = models.CharField(max_length=50, null=True, blank=False)
"""Backwards compatibility with the old CncNetClient."""

ip_address = models.CharField(max_length=50, null=True, blank=True, db_index=True)
"""IP address that uploaded the file. 50 is long enough for ipv6"""

def validate_file_extension(self, file_extension: game_models.CncFileExtension) -> None:
"""Validate that an extension is supported for a game.

Expand Down
12 changes: 12 additions & 0 deletions kirovy/request.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from functools import cached_property

from rest_framework.request import Request as _DRFRequest
from kirovy import models, typing as t, objects

Expand All @@ -11,3 +13,13 @@ class KirovyRequest(_DRFRequest):

user: t.Optional[models.CncUser]
auth: t.Optional[objects.CncnetUserInfo]

@cached_property
def client_ip_address(self) -> str:
if self.user.is_staff:
return "staff"
x_forwarded_for: str | None = self.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
return x_forwarded_for.split(",")[0].strip()

return self.META.get("REMOTE_ADDR", "unknown")
17 changes: 13 additions & 4 deletions kirovy/serializers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from functools import cached_property

from rest_framework import serializers

from kirovy.constants import api_codes
Expand All @@ -18,25 +20,33 @@ class KirovySerializer(serializers.Serializer):
source="last_modified_by",
queryset=CncUser.objects.all(),
pk_field=serializers.UUIDField(),
allow_null=True,
)

class Meta:
exclude = ["last_modified_by"]
fields = "__all__"
editable_fields: t.ClassVar[set[str]] = set()

def get_fields(self):
@cached_property
def permissioned_readable_fields(self):
"""Get fields based on permission level.

Removes admin-only fields for non-admin requests. Will always remove the fields if the serializer doesn't
have context.
"""
fields = super().get_fields()
fields = self.fields
request: t.Optional[KirovyRequest] = self.context.get("request")
if not (request and request.user.is_authenticated and request.user.is_staff):
fields.pop("last_modified_by_id", None)
fields.pop("ip_address", None)
return fields

@property
def _readable_fields(self):
for field in self.permissioned_readable_fields.values():
if not field.write_only:
yield field

def to_internal_value(self, data: dict) -> dict:
"""Convert the raw request data into data that can be used in a django model.

Expand Down Expand Up @@ -72,4 +82,3 @@ class CncNetUserOwnedModelSerializer(KirovySerializer):

class Meta:
exclude = ["cnc_user"]
fields = "__all__"
Loading