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
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 4.2.26 on 2025-11-23 00:19

from django.db import migrations, models
import kirovy.models.file_base


class Migration(migrations.Migration):

dependencies = [
("kirovy", "0019_alter_cncmapimagefile_file"),
]

operations = [
migrations.AlterField(
model_name="cncmapfile",
name="file",
field=models.FileField(max_length=2048, upload_to=kirovy.models.file_base.default_generate_upload_to),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URLs were getting truncated by the very short default length

),
migrations.AlterField(
model_name="cncmapimagefile",
name="file",
field=models.ImageField(max_length=2048, upload_to=kirovy.models.file_base.default_generate_upload_to),
),
migrations.AlterField(
model_name="mappreview",
name="file",
field=models.FileField(max_length=2048, upload_to=kirovy.models.file_base.default_generate_upload_to),
),
]
2 changes: 1 addition & 1 deletion kirovy/models/cnc_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ class CncMapImageFile(file_base.CncNetFileBaseModel):

UPLOAD_TYPE = settings.CNC_MAP_IMAGE_DIRECTORY

file = models.ImageField(null=False, upload_to=file_base.default_generate_upload_to)
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)
Expand Down
4 changes: 2 additions & 2 deletions kirovy/models/file_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ class Meta:
name = models.CharField(max_length=255, null=False, blank=False)
"""Filename no extension."""

file = models.FileField(null=False, upload_to=default_generate_upload_to)
"""The actual file this object represent."""
file = models.FileField(null=False, upload_to=default_generate_upload_to, max_length=2048)
"""The actual file this object represent. The max length of 2048 is half of the unix max."""

file_extension = models.ForeignKey(
game_models.CncFileExtension,
Expand Down
91 changes: 91 additions & 0 deletions kirovy/serializers/cnc_game_serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from rest_framework import serializers
from kirovy import typing as t
from kirovy.models import cnc_game
from kirovy.serializers import KirovySerializer


class CncFileExtensionSerializer(KirovySerializer):
extension = serializers.CharField(
max_length=32,
allow_blank=False,
)

about = serializers.CharField(
max_length=2048,
allow_null=True,
allow_blank=False,
required=False,
)

extension_type = serializers.ChoiceField(
choices=cnc_game.CncFileExtension.ExtensionTypes.choices,
)

def create(self, validated_data: dict[str, t.Any]) -> cnc_game.CncFileExtension:
return cnc_game.CncFileExtension.objects.create(**validated_data)

def update(
self, instance: cnc_game.CncFileExtension, validated_data: dict[str, t.Any]
) -> cnc_game.CncFileExtension:
# For now, don't allow editing the extension. These likely shouldn't ever need to be updated.
# instance.extension = validated_data.get("extension", instance.extension)
instance.about = validated_data.get("about", instance.about)
instance.extension_type = validated_data.get("extension_type", instance.extension_type)
instance.save(update_fields=["about", "extension_type"])
instance.refresh_from_db()
return instance

class Meta:
model = cnc_game.CncFileExtension
exclude = ["last_modified_by"]
fields = "__all__"


class CncGameSerializer(KirovySerializer):
slug = serializers.CharField(read_only=True, allow_null=False, allow_blank=False)
full_name = serializers.CharField(allow_null=False, allow_blank=False)
is_visible = serializers.BooleanField(allow_null=False, default=True)
allow_public_uploads = serializers.BooleanField(allow_null=False, default=False)
compatible_with_parent_maps = serializers.BooleanField(allow_null=False, default=False)
is_mod = serializers.BooleanField(read_only=True, allow_null=False, default=False)
allowed_extension_ids = serializers.PrimaryKeyRelatedField(
source="allowed_extensions",
pk_field=serializers.UUIDField(),
many=True,
read_only=True, # Set these manually using the ORM.
)

parent_game_id = serializers.PrimaryKeyRelatedField(
source="parent_game",
pk_field=serializers.UUIDField(),
many=False,
allow_null=True,
allow_empty=False,
default=None,
read_only=True, # parent_id affects file path generation so we can't change it via the API.
)

class Meta:
model = cnc_game.CncGame
# We return the ID instead of the whole object.
exclude = ["parent_game", "allowed_extensions"]
fields = "__all__"

def create(self, validated_data: t.DictStrAny) -> cnc_game.CncGame:
instance = cnc_game.CncGame(**validated_data)
instance.save()
return instance

def update(self, instance: cnc_game.CncGame, validated_data: t.DictStrAny) -> cnc_game.CncGame:
instance.full_name = validated_data.get("full_name", instance.full_name)
instance.is_visible = validated_data.get("is_visible", instance.is_visible)
instance.is_mod = validated_data.get("is_mod", instance.is_mod)
instance.allow_public_uploads = validated_data.get("allow_public_uploads", instance.allow_public_uploads)
instance.compatible_with_parent_maps = validated_data.get(
"compatible_with_parent_maps", instance.compatible_with_parent_maps
)
instance.save(
update_fields=["full_name", "is_visible", "is_mod", "allow_public_uploads", "compatible_with_parent_maps"]
)
instance.refresh_from_db()
return instance
19 changes: 17 additions & 2 deletions kirovy/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@
import kirovy.views.map_image_views
from kirovy.models import CncGame
from kirovy.settings import settings_constants
from kirovy.views import test, cnc_map_views, permission_views, admin_views, map_upload_views, map_image_views
from kirovy.views import (
test,
cnc_map_views,
permission_views,
admin_views,
map_upload_views,
map_image_views,
game_views,
)
from kirovy import typing as t, constants

_DjangoPath = URLPattern | URLResolver
Expand Down Expand Up @@ -90,7 +98,7 @@ def _get_url_patterns() -> list[_DjangoPath]:
path("ui-permissions/", permission_views.ListPermissionForAuthUser.as_view()),
path("maps/", include(map_patterns)),
# path("users/<uuid:cnc_user_id>/", ...), # will show which files a user has uploaded.
# path("games/", ...), # get games.,
path("games/", include(game_patterns)),
]
+ backwards_compatible_urls
+ static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) # static assets
Expand Down Expand Up @@ -123,4 +131,11 @@ def _get_url_patterns() -> list[_DjangoPath]:
# /admin/
admin_patterns = [path("ban/", admin_views.BanView.as_view())]


# /game
game_patterns = [
path("", game_views.GamesListView.as_view()),
path("<uuid:pk>/", game_views.GameDetailView.as_view()),
]

urlpatterns = _get_url_patterns()
2 changes: 1 addition & 1 deletion kirovy/views/cnc_map_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ def get_queryset(self) -> QuerySet[CncMap]:
# Staff users can see everything.
return CncMap.objects.filter()

# Anyone can view legacy maps, temporary maps (for the cncnet client,) and published maps that aren't banned.
# Anyone can view legacy maps, temporary maps (cncnet client uploads) and published maps that aren't banned.
queryset: QuerySet[CncMap] = CncMap.objects.filter(
Q(Q(is_published=True) | Q(is_legacy=True) | Q(is_temporary=True)) & Q(is_banned=False)
)
Expand Down
32 changes: 32 additions & 0 deletions kirovy/views/game_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from django.db.models import QuerySet

from kirovy import permissions, typing as t
from kirovy.models import CncGame
from kirovy.serializers import cnc_game_serializers
from kirovy.views.base_views import KirovyListCreateView, KirovyDefaultPagination, KirovyRetrieveUpdateView


class GamesListView(KirovyListCreateView):

permission_classes = [permissions.IsAdmin | permissions.ReadOnly]
pagination_class: t.Type[KirovyDefaultPagination] | None = KirovyDefaultPagination
serializer_class = cnc_game_serializers.CncGameSerializer

def get_queryset(self) -> QuerySet[CncGame]:
if self.request.user.is_staff:
return CncGame.objects.all()

return CncGame.objects.filter(is_visible=True)


class GameDetailView(KirovyRetrieveUpdateView):

permission_classes = [permissions.IsAdmin | permissions.ReadOnly]
pagination_class: t.Type[KirovyDefaultPagination] | None = KirovyDefaultPagination
serializer_class = cnc_game_serializers.CncGameSerializer

def get_queryset(self) -> QuerySet[CncGame]:
if self.request.user.is_staff:
return CncGame.objects.all()

return CncGame.objects.filter(is_visible=True)
8 changes: 6 additions & 2 deletions kirovy/views/map_upload_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
uploaded_file: UploadedFile = request.data["file"]

game = self.get_game_from_request(request)
if not game:
game_supports_uploads = game and (request.user.is_staff or (game.is_visible and game.allow_public_uploads))
if not game_supports_uploads:
# Gaslight the user
raise KirovyValidationError(detail="Game does not exist", code=UploadApiCodes.GAME_DOES_NOT_EXIST)
extension_id = self.get_extension_id_for_upload(uploaded_file)
self.verify_file_size_is_allowed(uploaded_file)
Expand All @@ -77,6 +79,7 @@ def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
incomplete_upload=True,
cnc_user_id=request.user.id,
parent_id=parent_map.id if parent_map else None,
last_modified_by_id=request.user.id,
),
context={"request": self.request},
)
Expand Down Expand Up @@ -134,6 +137,7 @@ def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
hash_sha512=map_hashes_post_processing.sha512,
hash_sha1=map_hashes_post_processing.sha1,
cnc_user_id=self.request.user.id,
last_modified_by_id=self.request.user.id,
),
context={"request": self.request},
)
Expand Down Expand Up @@ -325,7 +329,7 @@ def get_game_from_request(self, request: KirovyRequest) -> CncGame | None:


class CncnetClientMapUploadView(_BaseMapFileUploadView):
"""DO NOT USE THIS FOR NOW. Use"""
"""DO NOT USE THIS FOR NOW. Use CncNetBackwardsCompatibleUploadView"""

permission_classes = [AllowAny]
upload_is_temporary = True
Expand Down
52 changes: 49 additions & 3 deletions tests/fixtures/common_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from kirovy import objects, typing as t, constants
from kirovy.models import CncUser
from kirovy.objects import ui_objects
from kirovy.objects.ui_objects import ErrorResponseData, BanData
from kirovy.response import KirovyResponse

Expand Down Expand Up @@ -69,6 +70,8 @@ def tmp_media_root(tmp_path, settings):

_ClientReturnT = KirovyResponse | FileResponse

_ClientResponseDataT = t.TypeVar("_ClientResponseDataT", bound=ui_objects.BaseResponseData)


class KirovyClient(Client):
"""A client wrapper with defaults I prefer.
Expand Down Expand Up @@ -146,8 +149,9 @@ def post(
content_type=__application_json,
follow=False,
secure=False,
data_type: t.Type[_ClientResponseDataT] = ui_objects.BaseResponseData,
**extra,
) -> _ClientReturnT:
) -> KirovyResponse[_ClientResponseDataT]:
"""Wraps post to make it default to JSON."""

data = self.__convert_data(data, content_type)
Expand All @@ -170,15 +174,30 @@ def post(
**extra,
)

def post_file(
self,
path: str,
data: dict[str, t.Any] | None = None,
data_type: t.Type[_ClientResponseDataT] = ui_objects.ResultResponseData,
**extra,
) -> KirovyResponse[_ClientResponseDataT]:
return super().post(
path,
data=data,
format="multipart",
**extra,
)

def patch(
self,
path,
data: JsonLike = "",
content_type=__application_json,
follow=False,
secure=False,
data_type: t.Type[_ClientResponseDataT] = ui_objects.BaseResponseData,
**extra,
) -> _ClientReturnT:
) -> KirovyResponse[_ClientResponseDataT]:
"""Wraps patch to make it default to JSON."""

data = self.__convert_data(data, content_type)
Expand All @@ -198,8 +217,9 @@ def put(
content_type=__application_json,
follow=False,
secure=False,
data_type: t.Type[_ClientResponseDataT] = ui_objects.BaseResponseData,
**extra,
) -> _ClientReturnT:
) -> KirovyResponse[_ClientResponseDataT]:
"""Wraps put to make it default to JSON."""

data = self.__convert_data(data, content_type)
Expand All @@ -212,6 +232,32 @@ def put(
**extra,
)

def get(
self,
path: str,
data: t.DictStrAny | None = None,
follow: bool = False,
secure: bool = False,
data_type: t.Type[_ClientResponseDataT] = ui_objects.BaseResponseData,
*,
headers: t.DictStrAny | None = None,
**extra: t.DictStrAny,
) -> KirovyResponse[_ClientResponseDataT]:
return super().get(path, data, follow, secure, headers=headers, **extra)

def get_file(
self,
path: str,
data: t.DictStrAny | None = None,
follow: bool = False,
secure: bool = False,
*,
headers: t.DictStrAny | None = None,
**extra: t.DictStrAny,
) -> FileResponse:
"""Wraps get to type hint a file return."""
return super().get(path, data, follow, secure, headers=headers, **extra)


@pytest.fixture
def create_client(db, tmp_media_root):
Expand Down
34 changes: 34 additions & 0 deletions tests/fixtures/extension_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,37 @@ def extension_mix(db) -> k_models.CncFileExtension:
The extension object for .mix files.
"""
return k_models.CncFileExtension.objects.get(extension="mix")


@pytest.fixture
def create_cnc_file_extension(db):
"""Return a function to create a CNC file extension."""

def _inner(
extension: str = "map",
about: str = "A Generals map file.",
extension_type: k_models.CncFileExtension.ExtensionTypes = k_models.CncFileExtension.ExtensionTypes.MAP,
) -> k_models.CncFileExtension:
"""Create a CNC file extension.

:param extension:
The actual file extension at the end of a filepath. Don't include the `.` prefix.
:param about:
A description of the file extension.
:param extension_type:
The type of file extension.
:return:
A CNC file extension object.
"""
file_extension = k_models.CncFileExtension(extension=extension, about=about, extension_type=extension_type)
file_extension.save()
file_extension.refresh_from_db()
return file_extension

return _inner


@pytest.fixture
def cnc_file_extension(create_cnc_file_extension) -> k_models.CncFileExtension:
"""Convenience wrapper to make a CncFileExtension for a test."""
return create_cnc_file_extension()
Loading