Skip to content

Commit 361b92e

Browse files
authored
feat: Game endpoints (#31)
- Fix filenames overflowing the default django 100 character length and getting truncated weirdly. - Fixtures for extensions and games - Fix tests that were broken by enforcing game uploadability. The games in 0001 are default non-uploadable so tests using `game_yuri` broke. Backwards compatible uploads ignore game visibility. - Enforce game uploadability - Fix map uploads for moderators. - Finish the game endpoints
1 parent dea4087 commit 361b92e

16 files changed

+501
-44
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Generated by Django 4.2.26 on 2025-11-23 00:19
2+
3+
from django.db import migrations, models
4+
import kirovy.models.file_base
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("kirovy", "0019_alter_cncmapimagefile_file"),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name="cncmapfile",
16+
name="file",
17+
field=models.FileField(max_length=2048, upload_to=kirovy.models.file_base.default_generate_upload_to),
18+
),
19+
migrations.AlterField(
20+
model_name="cncmapimagefile",
21+
name="file",
22+
field=models.ImageField(max_length=2048, upload_to=kirovy.models.file_base.default_generate_upload_to),
23+
),
24+
migrations.AlterField(
25+
model_name="mappreview",
26+
name="file",
27+
field=models.FileField(max_length=2048, upload_to=kirovy.models.file_base.default_generate_upload_to),
28+
),
29+
]

kirovy/models/cnc_map.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ class CncMapImageFile(file_base.CncNetFileBaseModel):
240240

241241
UPLOAD_TYPE = settings.CNC_MAP_IMAGE_DIRECTORY
242242

243-
file = models.ImageField(null=False, upload_to=file_base.default_generate_upload_to)
243+
file = models.ImageField(null=False, upload_to=file_base.default_generate_upload_to, max_length=2048)
244244
"""The actual file this object represent."""
245245

246246
is_extracted = models.BooleanField(null=False, blank=False, default=False)

kirovy/models/file_base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ class Meta:
3434
name = models.CharField(max_length=255, null=False, blank=False)
3535
"""Filename no extension."""
3636

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

4040
file_extension = models.ForeignKey(
4141
game_models.CncFileExtension,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from rest_framework import serializers
2+
from kirovy import typing as t
3+
from kirovy.models import cnc_game
4+
from kirovy.serializers import KirovySerializer
5+
6+
7+
class CncFileExtensionSerializer(KirovySerializer):
8+
extension = serializers.CharField(
9+
max_length=32,
10+
allow_blank=False,
11+
)
12+
13+
about = serializers.CharField(
14+
max_length=2048,
15+
allow_null=True,
16+
allow_blank=False,
17+
required=False,
18+
)
19+
20+
extension_type = serializers.ChoiceField(
21+
choices=cnc_game.CncFileExtension.ExtensionTypes.choices,
22+
)
23+
24+
def create(self, validated_data: dict[str, t.Any]) -> cnc_game.CncFileExtension:
25+
return cnc_game.CncFileExtension.objects.create(**validated_data)
26+
27+
def update(
28+
self, instance: cnc_game.CncFileExtension, validated_data: dict[str, t.Any]
29+
) -> cnc_game.CncFileExtension:
30+
# For now, don't allow editing the extension. These likely shouldn't ever need to be updated.
31+
# instance.extension = validated_data.get("extension", instance.extension)
32+
instance.about = validated_data.get("about", instance.about)
33+
instance.extension_type = validated_data.get("extension_type", instance.extension_type)
34+
instance.save(update_fields=["about", "extension_type"])
35+
instance.refresh_from_db()
36+
return instance
37+
38+
class Meta:
39+
model = cnc_game.CncFileExtension
40+
exclude = ["last_modified_by"]
41+
fields = "__all__"
42+
43+
44+
class CncGameSerializer(KirovySerializer):
45+
slug = serializers.CharField(read_only=True, allow_null=False, allow_blank=False)
46+
full_name = serializers.CharField(allow_null=False, allow_blank=False)
47+
is_visible = serializers.BooleanField(allow_null=False, default=True)
48+
allow_public_uploads = serializers.BooleanField(allow_null=False, default=False)
49+
compatible_with_parent_maps = serializers.BooleanField(allow_null=False, default=False)
50+
is_mod = serializers.BooleanField(read_only=True, allow_null=False, default=False)
51+
allowed_extension_ids = serializers.PrimaryKeyRelatedField(
52+
source="allowed_extensions",
53+
pk_field=serializers.UUIDField(),
54+
many=True,
55+
read_only=True, # Set these manually using the ORM.
56+
)
57+
58+
parent_game_id = serializers.PrimaryKeyRelatedField(
59+
source="parent_game",
60+
pk_field=serializers.UUIDField(),
61+
many=False,
62+
allow_null=True,
63+
allow_empty=False,
64+
default=None,
65+
read_only=True, # parent_id affects file path generation so we can't change it via the API.
66+
)
67+
68+
class Meta:
69+
model = cnc_game.CncGame
70+
# We return the ID instead of the whole object.
71+
exclude = ["parent_game", "allowed_extensions"]
72+
fields = "__all__"
73+
74+
def create(self, validated_data: t.DictStrAny) -> cnc_game.CncGame:
75+
instance = cnc_game.CncGame(**validated_data)
76+
instance.save()
77+
return instance
78+
79+
def update(self, instance: cnc_game.CncGame, validated_data: t.DictStrAny) -> cnc_game.CncGame:
80+
instance.full_name = validated_data.get("full_name", instance.full_name)
81+
instance.is_visible = validated_data.get("is_visible", instance.is_visible)
82+
instance.is_mod = validated_data.get("is_mod", instance.is_mod)
83+
instance.allow_public_uploads = validated_data.get("allow_public_uploads", instance.allow_public_uploads)
84+
instance.compatible_with_parent_maps = validated_data.get(
85+
"compatible_with_parent_maps", instance.compatible_with_parent_maps
86+
)
87+
instance.save(
88+
update_fields=["full_name", "is_visible", "is_mod", "allow_public_uploads", "compatible_with_parent_maps"]
89+
)
90+
instance.refresh_from_db()
91+
return instance

kirovy/urls.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,15 @@
2424
import kirovy.views.map_image_views
2525
from kirovy.models import CncGame
2626
from kirovy.settings import settings_constants
27-
from kirovy.views import test, cnc_map_views, permission_views, admin_views, map_upload_views, map_image_views
27+
from kirovy.views import (
28+
test,
29+
cnc_map_views,
30+
permission_views,
31+
admin_views,
32+
map_upload_views,
33+
map_image_views,
34+
game_views,
35+
)
2836
from kirovy import typing as t, constants
2937

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

134+
135+
# /game
136+
game_patterns = [
137+
path("", game_views.GamesListView.as_view()),
138+
path("<uuid:pk>/", game_views.GameDetailView.as_view()),
139+
]
140+
126141
urlpatterns = _get_url_patterns()

kirovy/views/cnc_map_views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ def get_queryset(self) -> QuerySet[CncMap]:
224224
# Staff users can see everything.
225225
return CncMap.objects.filter()
226226

227-
# Anyone can view legacy maps, temporary maps (for the cncnet client,) and published maps that aren't banned.
227+
# Anyone can view legacy maps, temporary maps (cncnet client uploads) and published maps that aren't banned.
228228
queryset: QuerySet[CncMap] = CncMap.objects.filter(
229229
Q(Q(is_published=True) | Q(is_legacy=True) | Q(is_temporary=True)) & Q(is_banned=False)
230230
)

kirovy/views/game_views.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from django.db.models import QuerySet
2+
3+
from kirovy import permissions, typing as t
4+
from kirovy.models import CncGame
5+
from kirovy.serializers import cnc_game_serializers
6+
from kirovy.views.base_views import KirovyListCreateView, KirovyDefaultPagination, KirovyRetrieveUpdateView
7+
8+
9+
class GamesListView(KirovyListCreateView):
10+
11+
permission_classes = [permissions.IsAdmin | permissions.ReadOnly]
12+
pagination_class: t.Type[KirovyDefaultPagination] | None = KirovyDefaultPagination
13+
serializer_class = cnc_game_serializers.CncGameSerializer
14+
15+
def get_queryset(self) -> QuerySet[CncGame]:
16+
if self.request.user.is_staff:
17+
return CncGame.objects.all()
18+
19+
return CncGame.objects.filter(is_visible=True)
20+
21+
22+
class GameDetailView(KirovyRetrieveUpdateView):
23+
24+
permission_classes = [permissions.IsAdmin | permissions.ReadOnly]
25+
pagination_class: t.Type[KirovyDefaultPagination] | None = KirovyDefaultPagination
26+
serializer_class = cnc_game_serializers.CncGameSerializer
27+
28+
def get_queryset(self) -> QuerySet[CncGame]:
29+
if self.request.user.is_staff:
30+
return CncGame.objects.all()
31+
32+
return CncGame.objects.filter(is_visible=True)

kirovy/views/map_upload_views.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
5757
uploaded_file: UploadedFile = request.data["file"]
5858

5959
game = self.get_game_from_request(request)
60-
if not game:
60+
game_supports_uploads = game and (request.user.is_staff or (game.is_visible and game.allow_public_uploads))
61+
if not game_supports_uploads:
62+
# Gaslight the user
6163
raise KirovyValidationError(detail="Game does not exist", code=UploadApiCodes.GAME_DOES_NOT_EXIST)
6264
extension_id = self.get_extension_id_for_upload(uploaded_file)
6365
self.verify_file_size_is_allowed(uploaded_file)
@@ -77,6 +79,7 @@ def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
7779
incomplete_upload=True,
7880
cnc_user_id=request.user.id,
7981
parent_id=parent_map.id if parent_map else None,
82+
last_modified_by_id=request.user.id,
8083
),
8184
context={"request": self.request},
8285
)
@@ -134,6 +137,7 @@ def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
134137
hash_sha512=map_hashes_post_processing.sha512,
135138
hash_sha1=map_hashes_post_processing.sha1,
136139
cnc_user_id=self.request.user.id,
140+
last_modified_by_id=self.request.user.id,
137141
),
138142
context={"request": self.request},
139143
)
@@ -325,7 +329,7 @@ def get_game_from_request(self, request: KirovyRequest) -> CncGame | None:
325329

326330

327331
class CncnetClientMapUploadView(_BaseMapFileUploadView):
328-
"""DO NOT USE THIS FOR NOW. Use"""
332+
"""DO NOT USE THIS FOR NOW. Use CncNetBackwardsCompatibleUploadView"""
329333

330334
permission_classes = [AllowAny]
331335
upload_is_temporary = True

tests/fixtures/common_fixtures.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from kirovy import objects, typing as t, constants
1919
from kirovy.models import CncUser
20+
from kirovy.objects import ui_objects
2021
from kirovy.objects.ui_objects import ErrorResponseData, BanData
2122
from kirovy.response import KirovyResponse
2223

@@ -69,6 +70,8 @@ def tmp_media_root(tmp_path, settings):
6970

7071
_ClientReturnT = KirovyResponse | FileResponse
7172

73+
_ClientResponseDataT = t.TypeVar("_ClientResponseDataT", bound=ui_objects.BaseResponseData)
74+
7275

7376
class KirovyClient(Client):
7477
"""A client wrapper with defaults I prefer.
@@ -146,8 +149,9 @@ def post(
146149
content_type=__application_json,
147150
follow=False,
148151
secure=False,
152+
data_type: t.Type[_ClientResponseDataT] = ui_objects.BaseResponseData,
149153
**extra,
150-
) -> _ClientReturnT:
154+
) -> KirovyResponse[_ClientResponseDataT]:
151155
"""Wraps post to make it default to JSON."""
152156

153157
data = self.__convert_data(data, content_type)
@@ -170,15 +174,30 @@ def post(
170174
**extra,
171175
)
172176

177+
def post_file(
178+
self,
179+
path: str,
180+
data: dict[str, t.Any] | None = None,
181+
data_type: t.Type[_ClientResponseDataT] = ui_objects.ResultResponseData,
182+
**extra,
183+
) -> KirovyResponse[_ClientResponseDataT]:
184+
return super().post(
185+
path,
186+
data=data,
187+
format="multipart",
188+
**extra,
189+
)
190+
173191
def patch(
174192
self,
175193
path,
176194
data: JsonLike = "",
177195
content_type=__application_json,
178196
follow=False,
179197
secure=False,
198+
data_type: t.Type[_ClientResponseDataT] = ui_objects.BaseResponseData,
180199
**extra,
181-
) -> _ClientReturnT:
200+
) -> KirovyResponse[_ClientResponseDataT]:
182201
"""Wraps patch to make it default to JSON."""
183202

184203
data = self.__convert_data(data, content_type)
@@ -198,8 +217,9 @@ def put(
198217
content_type=__application_json,
199218
follow=False,
200219
secure=False,
220+
data_type: t.Type[_ClientResponseDataT] = ui_objects.BaseResponseData,
201221
**extra,
202-
) -> _ClientReturnT:
222+
) -> KirovyResponse[_ClientResponseDataT]:
203223
"""Wraps put to make it default to JSON."""
204224

205225
data = self.__convert_data(data, content_type)
@@ -212,6 +232,32 @@ def put(
212232
**extra,
213233
)
214234

235+
def get(
236+
self,
237+
path: str,
238+
data: t.DictStrAny | None = None,
239+
follow: bool = False,
240+
secure: bool = False,
241+
data_type: t.Type[_ClientResponseDataT] = ui_objects.BaseResponseData,
242+
*,
243+
headers: t.DictStrAny | None = None,
244+
**extra: t.DictStrAny,
245+
) -> KirovyResponse[_ClientResponseDataT]:
246+
return super().get(path, data, follow, secure, headers=headers, **extra)
247+
248+
def get_file(
249+
self,
250+
path: str,
251+
data: t.DictStrAny | None = None,
252+
follow: bool = False,
253+
secure: bool = False,
254+
*,
255+
headers: t.DictStrAny | None = None,
256+
**extra: t.DictStrAny,
257+
) -> FileResponse:
258+
"""Wraps get to type hint a file return."""
259+
return super().get(path, data, follow, secure, headers=headers, **extra)
260+
215261

216262
@pytest.fixture
217263
def create_client(db, tmp_media_root):

tests/fixtures/extension_fixtures.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,37 @@ def extension_mix(db) -> k_models.CncFileExtension:
2727
The extension object for .mix files.
2828
"""
2929
return k_models.CncFileExtension.objects.get(extension="mix")
30+
31+
32+
@pytest.fixture
33+
def create_cnc_file_extension(db):
34+
"""Return a function to create a CNC file extension."""
35+
36+
def _inner(
37+
extension: str = "map",
38+
about: str = "A Generals map file.",
39+
extension_type: k_models.CncFileExtension.ExtensionTypes = k_models.CncFileExtension.ExtensionTypes.MAP,
40+
) -> k_models.CncFileExtension:
41+
"""Create a CNC file extension.
42+
43+
:param extension:
44+
The actual file extension at the end of a filepath. Don't include the `.` prefix.
45+
:param about:
46+
A description of the file extension.
47+
:param extension_type:
48+
The type of file extension.
49+
:return:
50+
A CNC file extension object.
51+
"""
52+
file_extension = k_models.CncFileExtension(extension=extension, about=about, extension_type=extension_type)
53+
file_extension.save()
54+
file_extension.refresh_from_db()
55+
return file_extension
56+
57+
return _inner
58+
59+
60+
@pytest.fixture
61+
def cnc_file_extension(create_cnc_file_extension) -> k_models.CncFileExtension:
62+
"""Convenience wrapper to make a CncFileExtension for a test."""
63+
return create_cnc_file_extension()

0 commit comments

Comments
 (0)