Skip to content

Commit 1e88aa2

Browse files
committed
- Make kirovy exception handler more generic
- Add `/maps/img/id` edit endpoint - Add `editable_fields` to serializers, which allows for fields to be set during creation, but never allowed to be updated.
1 parent e40e131 commit 1e88aa2

File tree

12 files changed

+377
-194
lines changed

12 files changed

+377
-194
lines changed

kirovy/constants/api_codes.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,8 @@ class FileUploadApiCodes(enum.StrEnum):
3232
e.g. a temporary map does not support custom image uploads.
3333
"""
3434
TOO_LARGE = "file-too-large"
35+
36+
37+
class GenericApiCodes(enum.StrEnum):
38+
CANNOT_UPDATE_FIELD = "field-cannot-be-updated-after-creation"
39+
"""attr: Some fields are not allowed to be edited via any API endpoint."""

kirovy/exception_handler.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
from rest_framework import status
21
from rest_framework.views import exception_handler
32

4-
from kirovy.exceptions.view_exceptions import KirovyValidationError
3+
from kirovy.exceptions.view_exceptions import KirovyAPIException
54
from kirovy.objects import ui_objects
65
from kirovy.response import KirovyResponse
76

@@ -23,7 +22,7 @@ def kirovy_exception_handler(exception: Exception, context) -> KirovyResponse[ui
2322
Returns the ``KirovyResponse`` if the exception is one we defined.
2423
Otherwise, it calls the base DRF exception handler :func:`rest_framework.views.exception_handler`.
2524
"""
26-
if isinstance(exception, KirovyValidationError):
27-
return KirovyResponse(exception.as_error_response_data(), status=status.HTTP_400_BAD_REQUEST)
25+
if isinstance(exception, KirovyAPIException):
26+
return KirovyResponse(exception.as_error_response_data(), status=exception.status_code)
2827

2928
return exception_handler(exception, context)

kirovy/exceptions/view_exceptions.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.utils.encoding import force_str
12
from rest_framework import status
23
from rest_framework.exceptions import APIException as _DRFAPIException
34
from django.utils.translation import gettext_lazy as _
@@ -6,17 +7,8 @@
67
from kirovy.objects import ui_objects
78

89

9-
class KirovyValidationError(_DRFAPIException):
10-
"""A custom exception that easily converts to the standard ``ErrorResponseData``
11-
12-
See: :class:`kirovy.objects.ui_objects.ErrorResponseData`
13-
14-
This exception is meant to be used within serializers or views.
15-
"""
16-
17-
status_code = status.HTTP_400_BAD_REQUEST
18-
default_detail = _("Invalid input.")
19-
default_code = "invalid"
10+
class KirovyAPIException(_DRFAPIException):
11+
status_code: _t.ClassVar[int] = status.HTTP_500_INTERNAL_SERVER_ERROR
2012
additional: _t.DictStrAny | None = None
2113
code: str | None
2214
"""attr: Some kind of string that the UI will recognize. e.g. ``file-too-large``.
@@ -41,3 +33,29 @@ def __init__(self, detail: str | None = None, code: str | None = None, additiona
4133

4234
def as_error_response_data(self) -> ui_objects.ErrorResponseData:
4335
return ui_objects.ErrorResponseData(message=self.detail, code=self.code, additional=self.additional)
36+
37+
38+
class KirovyValidationError(KirovyAPIException):
39+
"""A custom exception that easily converts to the standard ``ErrorResponseData``
40+
41+
See: :class:`kirovy.objects.ui_objects.ErrorResponseData`
42+
43+
This exception is meant to be used within serializers or views.
44+
"""
45+
46+
status_code = status.HTTP_400_BAD_REQUEST
47+
default_detail = _("Invalid input.")
48+
default_code = "invalid"
49+
50+
51+
class KirovyMethodNotAllowed(KirovyAPIException):
52+
status_code = status.HTTP_405_METHOD_NOT_ALLOWED
53+
default_detail = _('Method "{method}" not allowed.')
54+
default_code = "method_not_allowed"
55+
56+
def __init__(
57+
self, method, detail: str | None = None, code: str | None = None, additional: _t.DictStrAny | None = None
58+
):
59+
if detail is None:
60+
detail = force_str(self.default_detail).format(method=method)
61+
super().__init__(detail, code, additional)

kirovy/serializers/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from rest_framework import serializers
22

3+
from kirovy.constants import api_codes
4+
from kirovy.exceptions.view_exceptions import KirovyValidationError
35
from kirovy.models import CncUser
46
from kirovy import typing as t
57
from kirovy.request import KirovyRequest
@@ -21,6 +23,7 @@ class KirovySerializer(serializers.Serializer):
2123
class Meta:
2224
exclude = ["last_modified_by"]
2325
fields = "__all__"
26+
editable_fields: t.ClassVar[set[str]] = set()
2427

2528
def get_fields(self):
2629
"""Get fields based on permission level.
@@ -34,6 +37,29 @@ def get_fields(self):
3437
fields.pop("last_modified_by_id", None)
3538
return fields
3639

40+
def to_internal_value(self, data: dict) -> dict:
41+
"""Convert the raw request data into data that can be used in a django model.
42+
43+
Enforces editable fields from :attr:`~kirovy.serializers.KirovySerializer.Meta.editable.fields`.
44+
"""
45+
data = super().to_internal_value(data)
46+
# Enforce editable fields.
47+
if self.instance and hasattr(self.Meta, "editable_fields") and self.Meta.editable_fields:
48+
updated_keys = set(data.keys())
49+
if attempted_disallowed_updates := list(updated_keys - self.Meta.editable_fields):
50+
# `to_internal_value` converts `data["thing_id"]` to `data["thing"]`.
51+
# If you're getting this error on e.g. `thing_id`, but you specified `thing_id` as editable,
52+
# then you need to change the name in `editable_fields` to `thing`.
53+
raise KirovyValidationError(
54+
"Requested fields cannot be edited after creation",
55+
api_codes.GenericApiCodes.CANNOT_UPDATE_FIELD,
56+
additional={
57+
"can_update": list(self.Meta.editable_fields),
58+
"attempted": attempted_disallowed_updates,
59+
},
60+
)
61+
return data
62+
3763

3864
class CncNetUserOwnedModelSerializer(KirovySerializer):
3965
"""Base serializer for any model that mixes in :class:`~kirovy.models.cnc_user.CncNetUserOwnedModel`"""

kirovy/serializers/cnc_map_serializers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ class Meta:
109109
# We return the ID instead of the whole object.
110110
exclude = ["cnc_user", "cnc_game", "cnc_map", "file_extension", "hash_md5", "hash_sha512", "hash_sha1"]
111111
fields = "__all__"
112+
editable_fields: set[str] = {"name", "image_order"}
112113

113114
width = serializers.IntegerField()
114115
"""attr: The map height.

kirovy/urls.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
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
27+
from kirovy.views import test, cnc_map_views, permission_views, admin_views, map_upload_views, map_image_views
2828
from kirovy import typing as t, constants
2929

3030
_DjangoPath = URLPattern | URLResolver
@@ -109,7 +109,8 @@ def _get_url_patterns() -> list[_DjangoPath]:
109109
path("<uuid:pk>/", cnc_map_views.MapRetrieveUpdateView.as_view()),
110110
path("delete/<uuid:pk>/", cnc_map_views.MapDeleteView.as_view()),
111111
path("search/", cnc_map_views.MapListView.as_view()),
112-
path("img/", kirovy.views.map_image_views.MapImageFileUploadView.as_view()),
112+
path("img/", map_image_views.MapImageFileUploadView.as_view()),
113+
path("img/<uuid:pk>/", map_image_views.MapImageFileRetrieveUpdate.as_view()),
113114
# path("img/<uuid:map_id>/", ...),
114115
# path("search/")
115116
]

kirovy/views/base_views.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,19 @@
66

77
from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile
88
from rest_framework import (
9-
exceptions as _e,
109
generics as _g,
1110
permissions as _p,
1211
pagination as _pagination,
1312
status,
1413
)
1514
from rest_framework.generics import get_object_or_404
1615
from rest_framework.parsers import MultiPartParser
17-
from rest_framework.response import Response
1816
from rest_framework.views import APIView
1917

2018
import kirovy.objects.ui_objects
2119
from kirovy import permissions, typing as t, logging
2220
from kirovy.constants import api_codes
23-
from kirovy.exceptions.view_exceptions import KirovyValidationError
21+
from kirovy.exceptions.view_exceptions import KirovyValidationError, KirovyMethodNotAllowed
2422
from kirovy.models import CncNetFileBaseModel
2523
from kirovy.models.cnc_game import GameScopedUserOwnedModel
2624
from kirovy.objects import ui_objects
@@ -124,16 +122,16 @@ def retrieve(self, request, *args, **kwargs):
124122
status=status.HTTP_200_OK,
125123
)
126124

127-
def put(self, request: KirovyRequest, *args, **kwargs) -> Response:
128-
raise _e.MethodNotAllowed(
125+
def put(self, request: KirovyRequest, *args, **kwargs) -> KirovyResponse:
126+
raise KirovyMethodNotAllowed(
129127
"PUT",
130128
"PUT is not allowed. Only use PATCH and only send fields that were modified.",
131129
)
132130

133-
def delete(self, request: KirovyRequest, *args, **kwargs) -> Response:
134-
raise _e.MethodNotAllowed(
131+
def delete(self, request: KirovyRequest, *args, **kwargs) -> KirovyResponse:
132+
raise KirovyMethodNotAllowed(
135133
"DELETE",
136-
"DELETE is not allowed on this endpoint. Please use the delete endpoint.",
134+
"DELETE is not allowed on this endpoint",
137135
)
138136

139137

kirovy/views/map_image_views.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from PIL import Image, UnidentifiedImageError
55
from PIL.Image import DecompressionBombError
66
from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile
7+
from django.db.models import QuerySet
78

89
from kirovy import permissions, typing as t
910
from kirovy.constants import api_codes
@@ -75,3 +76,16 @@ def modify_uploaded_file(
7576
image.convert("RGB").save(image_io, format="JPEG", quality=95)
7677

7778
return InMemoryUploadedFile(image_io, None, f"{filename}.jpg", "image/jpeg", image_io.tell(), None)
79+
80+
81+
class MapImageFileRetrieveUpdate(base_views.KirovyRetrieveUpdateView):
82+
"""Endpoint to edit the editable fields for a map image."""
83+
84+
serializer_class = cnc_map_serializers.CncMapImageFileSerializer
85+
86+
def get_queryset(self) -> QuerySet[CncMapImageFile]:
87+
base = CncMapImageFile.objects.filter(cnc_map__is_banned=False)
88+
if self.request.user.is_authenticated:
89+
return base | CncMapImageFile.objects.filter(cnc_user_id=self.request.user.id)
90+
91+
return base

tests/fixtures/common_fixtures.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from kirovy import objects, typing as t, constants
1919
from kirovy.models import CncUser
20-
from kirovy.objects.ui_objects import ErrorResponseData
20+
from kirovy.objects.ui_objects import ErrorResponseData, BanData
2121
from kirovy.response import KirovyResponse
2222

2323

@@ -376,3 +376,21 @@ def client_admin(admin, create_client) -> KirovyClient:
376376
def client_god(god, create_client) -> KirovyClient:
377377
"""Returns a client with an active god user."""
378378
return create_client(god)
379+
380+
381+
@pytest.fixture
382+
def ban_user(client_god):
383+
"""Return a function to ban a user."""
384+
385+
def _inner(user_to_ban: CncUser, ban_reason: str = "Silos Needed") -> CncUser:
386+
response = client_god.post(
387+
"/admin/ban/",
388+
BanData(
389+
object_type=BanData.Meta.ObjectType.USER, is_banned=True, note=ban_reason, object_id=str(user_to_ban.id)
390+
).model_dump_json(),
391+
)
392+
assert response.status_code == status.HTTP_200_OK
393+
user_to_ban.refresh_from_db()
394+
return user_to_ban
395+
396+
return _inner

tests/fixtures/map_fixtures.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from django.core.files import File
22
from django.db.models import UUIDField
3+
from rest_framework import status
34

4-
from kirovy.models import CncGame
5-
from kirovy.models.cnc_map import CncMap, CncMapFile, MapCategory
5+
from kirovy.models import CncGame, CncUser
6+
from kirovy.models.cnc_map import CncMap, CncMapFile, MapCategory, CncMapImageFile
67
from kirovy import typing as t
78
import pytest
89

@@ -169,3 +170,39 @@ def banned_cheat_map(create_cnc_map, file_map_unfair) -> CncMap:
169170
is_temporary=True,
170171
file=file_map_unfair,
171172
)
173+
174+
175+
@pytest.fixture
176+
def create_cnc_map_image_file(create_client, create_cnc_map, create_kirovy_user):
177+
"""Return a function to add an image to a map."""
178+
179+
def _inner(
180+
file: File,
181+
cnc_map: CncMap | None = None,
182+
user: CncUser | None = None,
183+
) -> CncMapImageFile:
184+
if cnc_map and not user:
185+
# The most common case is just passing a map.
186+
user = cnc_map.cnc_user
187+
else:
188+
# Only passed a user, or passed neither.
189+
if not user:
190+
user = create_kirovy_user()
191+
if not cnc_map:
192+
cnc_map = create_cnc_map(user_id=user.id)
193+
194+
assert cnc_map and user
195+
assert cnc_map.cnc_user_id == user.id
196+
197+
client = create_client(user)
198+
response = client.post(
199+
"/maps/img/",
200+
{"file": file, "cnc_map_id": str(cnc_map.id)},
201+
format="multipart",
202+
content_type=None,
203+
)
204+
assert response.status_code == status.HTTP_201_CREATED
205+
206+
return CncMapImageFile.objects.get(id=response.data["result"]["file_id"])
207+
208+
return _inner

0 commit comments

Comments
 (0)