Skip to content

Commit 44db2f9

Browse files
committed
- Start work on reusable file upload view
- Add user_id to file upload model - Some documentation - Stole the file extension getter from the map upload - Add todos for tomorrow. Going to need `BannableObject` sooner rather than later due to circular imports.
1 parent c56a030 commit 44db2f9

File tree

8 files changed

+164
-31
lines changed

8 files changed

+164
-31
lines changed

kirovy/constants/api_codes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,7 @@ class LegacyUploadApiCodes(enum.StrEnum):
2121
INVALID_FILE_TYPE = "invalid-file-type-in-zip"
2222
GAME_NOT_SUPPORTED = "game-not-supported"
2323
MAP_FAILED_TO_PARSE = "map-failed-to-parse"
24+
25+
26+
class FileUploadApiCodes(enum.StrEnum):
27+
MISSING_FOREIGN_ID = "missing-foreign-id"

kirovy/exceptions/view_exceptions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,19 @@ class KirovyValidationError(_DRFAPIException):
1919
default_code = "invalid"
2020
additional: _t.DictStrAny | None = None
2121
code: str | None
22+
"""attr: Some kind of string that the UI will recognize. e.g. ``file-too-large``.
23+
24+
Maps to the UI object attr :attr:`kirovy.objects.ui_objects.ErrorResponseData.code`.
25+
26+
.. warning::
27+
28+
This is **not** the HTTP code. The HTTP code will always be ``400`` for validation errors.
29+
"""
2230
detail: str | None
31+
"""attr: Extra detail in plain language. Think of this as a message for the user.
32+
33+
Maps to the UI object attr :attr:`kirovy.objects.ui_objects.ErrorResponseData.message`.
34+
"""
2335

2436
def __init__(self, detail: str | None = None, code: str | None = None, additional: _t.DictStrAny | None = None):
2537
super().__init__(detail=detail, code=code)

kirovy/models/cnc_game.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
import pathlib
12
from functools import cached_property
23

34
from django.conf import settings
5+
from django.core.files.uploadedfile import UploadedFile
46
from django.db import models
7+
from structlog import BoundLogger
58

69
from kirovy import exceptions, typing as t
710

811
from kirovy.models.cnc_base_model import CncNetBaseModel
912

10-
__all__ = ["CncFileExtension", "CncGame"]
13+
__all__ = ["CncFileExtension", "CncGame", "GameScopedUserOwnedModel"]
14+
15+
from kirovy.models.cnc_user import CncNetUserOwnedModel
1116

1217

1318
def is_valid_extension(extension: str) -> None:
@@ -73,6 +78,40 @@ def extension_for_path(self) -> str:
7378
"""
7479
return f".{self.extension}"
7580

81+
@classmethod
82+
def get_extension_id_for_upload(
83+
cls,
84+
uploaded_file: UploadedFile,
85+
allowed_types: t.Set[str],
86+
*,
87+
logger: BoundLogger,
88+
error_detail_upload_type: str,
89+
extra_log_attrs: t.Dict[str, t.Any] | None = None,
90+
) -> str:
91+
# TODO: Get rid of relative import after model import is removed from ui_objects.py
92+
from kirovy.constants.api_codes import UploadApiCodes
93+
from kirovy.exceptions.view_exceptions import KirovyValidationError
94+
95+
uploaded_extension = pathlib.Path(uploaded_file.name).suffix.lstrip(".").lower()
96+
# iexact is case insensitive
97+
kirovy_extension = cls.objects.filter(
98+
extension__iexact=uploaded_extension,
99+
extension_type__in=allowed_types,
100+
).first()
101+
102+
if kirovy_extension:
103+
return str(kirovy_extension.id)
104+
105+
logger.warning(
106+
"User attempted uploading unknown filetype",
107+
uploaded_extension=uploaded_extension,
108+
**(extra_log_attrs or {}), # todo: the userattrs should be a context tag for structlog.
109+
)
110+
raise KirovyValidationError(
111+
detail=f"'{uploaded_extension}' is not a valid {error_detail_upload_type.strip()} file extension.",
112+
code=UploadApiCodes.FILE_EXTENSION_NOT_SUPPORTED,
113+
)
114+
76115

77116
class CncGame(CncNetBaseModel):
78117
"""Represents C&C games and large total-conversion mods like Mental Omega."""
@@ -140,3 +179,10 @@ def logo_url(self) -> str:
140179

141180
def __repr__(self) -> str:
142181
return f"<{type(self).__name__} Object: ({self.slug}) '{self.full_name}' [{self.id}]>"
182+
183+
184+
class GameScopedUserOwnedModel(CncNetUserOwnedModel):
185+
cnc_game = models.ForeignKey(CncGame, models.PROTECT, null=False, blank=False)
186+
187+
class Meta:
188+
abstract = True

kirovy/models/cnc_map.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from kirovy.models import cnc_game as game_models, cnc_user
1010
from kirovy.models.cnc_base_model import CncNetBaseModel
1111
from kirovy import typing as t, exceptions
12+
from kirovy.models.cnc_game import GameScopedUserOwnedModel
1213

1314

1415
class MapCategory(CncNetBaseModel):
@@ -46,7 +47,7 @@ def save(
4647
super().save(force_insert, force_update, using, update_fields)
4748

4849

49-
class CncMap(cnc_user.CncNetUserOwnedModel):
50+
class CncMap(GameScopedUserOwnedModel):
5051
"""The Logical representation of a map for a Command & Conquer game.
5152
5253
We have this as a separate model from the file model because later C&C's allow for various files
@@ -108,7 +109,6 @@ class CncMap(cnc_user.CncNetUserOwnedModel):
108109
help_text="If true, then the map file has been uploaded, but the map info has not been set yet.",
109110
)
110111

111-
cnc_game = models.ForeignKey(game_models.CncGame, models.PROTECT, null=False)
112112
categories = models.ManyToManyField(MapCategory)
113113
parent = models.ForeignKey(
114114
"CncMap",

kirovy/models/file_base.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
from django.db import models
44

55
from kirovy import typing as t
6-
from kirovy.models.cnc_base_model import CncNetBaseModel
76
from kirovy.models import cnc_game as game_models
7+
from kirovy.models.cnc_game import GameScopedUserOwnedModel
88
from kirovy.utils import file_utils
99
from kirovy.zip_storage import ZipFileStorage
1010

@@ -24,7 +24,7 @@ def _generate_upload_to(instance: "CncNetFileBaseModel", filename: t.Union[str,
2424
return instance.generate_upload_to(instance, filename)
2525

2626

27-
class CncNetFileBaseModel(CncNetBaseModel):
27+
class CncNetFileBaseModel(GameScopedUserOwnedModel):
2828
class Meta:
2929
abstract = True
3030

@@ -51,9 +51,6 @@ class Meta:
5151
These are checked against :attr:`kirovy.models.cnc_game.CncFileExtension.extension_type`.
5252
"""
5353

54-
cnc_game = models.ForeignKey(game_models.CncGame, models.PROTECT, null=False, blank=False)
55-
"""Which game does this file belong to. Needed for file validation."""
56-
5754
hash_md5 = models.CharField(max_length=32, null=False, blank=False)
5855
"""Used for checking exact file duplicates."""
5956

kirovy/objects/ui_objects.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ class BanData(BaseModel):
2222
2323
- View: :class:`kirovy.views.admin_views.BanView`
2424
- URL: ``/admin/ban``
25+
26+
# TODO: Make a "bannable object" model and "get_model" to the same file to help with circular imports
2527
"""
2628

2729
class Meta:

kirovy/views/base_views.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,35 @@
22
Base views with common functionality for all API views in Kirovy
33
"""
44

5+
from abc import ABCMeta
6+
7+
import magic, mimetypes
8+
from django.core.files.uploadedfile import UploadedFile
59
from rest_framework import (
610
exceptions as _e,
711
generics as _g,
812
permissions as _p,
913
pagination as _pagination,
1014
status,
1115
)
16+
from rest_framework.generics import get_object_or_404
17+
from rest_framework.parsers import MultiPartParser
1218
from rest_framework.response import Response
19+
from rest_framework.views import APIView
1320

1421
import kirovy.objects.ui_objects
15-
from kirovy import permissions, typing as t
22+
from kirovy import permissions, typing as t, logging
23+
from kirovy.constants import api_codes
24+
from kirovy.exceptions.view_exceptions import KirovyValidationError
25+
from kirovy.models import CncNetFileBaseModel
26+
from kirovy.models.cnc_game import GameScopedUserOwnedModel, CncFileExtension
1627
from kirovy.objects import ui_objects
28+
from kirovy.permissions import CanUpload, CanEdit
1729
from kirovy.request import KirovyRequest
1830
from kirovy.response import KirovyResponse
19-
from kirovy.serializers import KirovySerializer
31+
from kirovy.serializers import KirovySerializer, CncNetUserOwnedModelSerializer
32+
from kirovy.serializers.cnc_map_serializers import CncMapImageFileSerializer
33+
from kirovy.utils import file_utils
2034

2135

2236
class KirovyDefaultPagination(_pagination.LimitOffsetPagination):
@@ -136,3 +150,73 @@ class KirovyDestroyView(_g.DestroyAPIView):
136150

137151
request: KirovyRequest # Added for type hinting. Populated by DRF ``.setup()``
138152
permission_classes = [permissions.CanDelete | _p.IsAdminUser]
153+
154+
155+
class FileUploadBaseView(APIView, metaclass=ABCMeta):
156+
parser_classes = [MultiPartParser]
157+
permission_classes: [CanUpload, CanEdit]
158+
request: KirovyRequest
159+
file_class: t.ClassVar[t.Type[CncNetFileBaseModel]]
160+
"""attr: The class for the file."""
161+
file_parent_class: t.ClassVar[t.Type[GameScopedUserOwnedModel]]
162+
"""attr: The class that the file will be linked to."""
163+
164+
file_parent_attr_name: t.ClassVar[str]
165+
"""attr: The name of the foreign key to the parent object.
166+
167+
e.g. ``cnc_game_id``.
168+
"""
169+
170+
serializer_class = t.ClassVar[t.Type[CncNetUserOwnedModelSerializer]]
171+
172+
def get_parent_object(self, request: KirovyRequest) -> GameScopedUserOwnedModel:
173+
174+
parent_object_id = request.data.get(self.file_parent_attr_name)
175+
if not parent_object_id:
176+
raise KirovyValidationError(
177+
detail="Must specify foreign key to parent object",
178+
code=api_codes.FileUploadApiCodes.MISSING_FOREIGN_ID,
179+
additional={"expected_field": self.file_parent_attr_name},
180+
)
181+
182+
parent_object: GameScopedUserOwnedModel = get_object_or_404(self.file_parent_class.objects, id=parent_object_id)
183+
self.check_object_permissions(request, parent_object)
184+
185+
return parent_object
186+
187+
def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
188+
uploaded_file: UploadedFile = request.data["file"]
189+
parent_object = self.get_parent_object(request)
190+
191+
# TODO BEFORE MEGE: Move to helper and 400 instead of 500
192+
# TODO: maybe DRY mr mime.
193+
magic_parser = magic.Magic(mime=True)
194+
uploaded_file.seek(0)
195+
mr_mime = magic_parser.from_buffer(uploaded_file.read())
196+
uploaded_file.seek(0)
197+
extension_id = file_utils.get_extension_id_for_upload(
198+
uploaded_file,
199+
self.file_class.ALLOWED_EXTENSION_TYPES,
200+
logger=logging.get_logger(),
201+
error_detail_upload_type="image",
202+
)
203+
serializer = self.serializer_class(
204+
data={
205+
"cnc_game_id": parent_object.cnc_game_id,
206+
self.file_parent_attr_name: parent_object.id,
207+
"name": request.get("name"),
208+
"file": uploaded_file,
209+
"file_extension_id": extension_id,
210+
**self.extra_serializer_data(request),
211+
}
212+
)
213+
214+
def extra_serializer_data(self, request: KirovyRequest) -> t.Dict[str, t.Any]:
215+
raise NotImplementedError()
216+
217+
218+
class MapImageFileUploadView(FileUploadBaseView):
219+
permission_classes = [permissions.CanEdit]
220+
serializer = CncMapImageFileSerializer
221+
222+
# TODO: Finish writing the subclass

kirovy/views/map_upload_views.py

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ def __init__(self, **kwargs):
5252
raise NotImplementedError("Must define what this endpoint sets for ``map_file.is_temporary``.")
5353

5454
def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
55-
# todo: add file version support.
56-
# todo: make validation less trash
55+
# todo: add support for uploading new version of maps.
56+
# todo: make validation less trash. This should probably all be in serializers.
5757
uploaded_file: UploadedFile = request.data["file"]
5858

5959
game = self.get_game_from_request(request)
6060
if not game:
61-
raise KirovyValidationError(detail="Game doesnot exist", code=UploadApiCodes.GAME_DOES_NOT_EXIST)
61+
raise KirovyValidationError(detail="Game does not exist", code=UploadApiCodes.GAME_DOES_NOT_EXIST)
6262
extension_id = self.get_extension_id_for_upload(uploaded_file)
6363
self.verify_file_size_is_allowed(uploaded_file)
6464

@@ -236,24 +236,12 @@ def get_game_from_request(self, request: KirovyRequest) -> CncGame | None:
236236
raise NotImplementedError()
237237

238238
def get_extension_id_for_upload(self, uploaded_file: UploadedFile) -> str:
239-
uploaded_extension = pathlib.Path(uploaded_file.name).suffix.lstrip(".").lower()
240-
# iexact is case insensitive
241-
kirovy_extension = CncFileExtension.objects.filter(
242-
extension__iexact=uploaded_extension,
243-
extension_type__in=cnc_map.CncMapFile.ALLOWED_EXTENSION_TYPES,
244-
).first()
245-
246-
if kirovy_extension:
247-
return str(kirovy_extension.id)
248-
249-
_LOGGER.warning(
250-
"User attempted uploading unknown filetype",
251-
uploaded_extension=uploaded_extension,
252-
**self.user_log_attrs, # todo: the userattrs should be a context tag for structlog.
253-
)
254-
raise serializers.ValidationError(
255-
detail=f"'{uploaded_extension}' is not a valid map file extension.",
256-
code=UploadApiCodes.FILE_EXTENSION_NOT_SUPPORTED,
239+
return CncFileExtension.get_extension_id_for_upload(
240+
uploaded_file,
241+
cnc_map.CncMapFile.ALLOWED_EXTENSION_TYPES,
242+
logger=_LOGGER,
243+
error_detail_upload_type="map",
244+
extra_log_attrs=self.user_log_attrs,
257245
)
258246

259247
def verify_file_does_not_exist(self, hashes: MapHashes) -> None:

0 commit comments

Comments
 (0)