Skip to content

Commit 5739813

Browse files
authored
feat: Query for maps and client upload (#10)
- Add better typing to the base views - The beginning of searching for maps - Some docs - Fix tests that I broke - Get search working - Test a few search cases - Update map fixtures - Start adding support for legacy map database - Start writing doc for pythonisms - Break up the map upload into a base class to allow overrides for legacy client support - Custom error handling for our custom validation error - Add `sha1` for legacy support - Lots of docstrings - Create cncnet client upload URL - Move error codes to an enum for uploads. - Add a really balanced map for testing
1 parent f4bd495 commit 5739813

34 files changed

+2866
-335
lines changed

docs/python/pythonism.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Python-isms that are used often
2+
3+
4+
## Find if list has *any* matches
5+
6+
This is a common problem. Say you have a list of objects with a `parent_id` attribute.
7+
You want to see if there is at least one object in the list where `parent_id` is `117`.
8+
9+
Python offers a one-liner to do this by abusing the [`next()`](https://docs.python.org/3/library/functions.html#next)
10+
function.
11+
12+
```python
13+
# my_objects is a list of objects with parent_id
14+
found_match = next(iter([x for x in my_list if x.parent_id == 117]))
15+
16+
if found_match:
17+
...
18+
```
19+
20+
- `[x for x in my_list if x.parent_id == 117]` is a python list comprehension that
21+
makes a list of all objects matching the `if` statement.
22+
- `iter()` converts the list to an iterator.
23+
- `next()` grabs the next item in the iterator.
24+
25+
This is an alternative to:
26+
27+
```python
28+
found_match: bool = False
29+
30+
for my_object in my_list:
31+
if my_object.parent_id == 117:
32+
found_match = True
33+
break
34+
35+
if found_match:
36+
...
37+
```
38+
39+
##### Pros
40+
41+
- One line
42+
- Easy to read for people familiar with this trick
43+
44+
##### Cons
45+
46+
- On massive lists, it is better to do the looping method. This is because the list comprehension creates a second
47+
list of **all** matches before calling `next()` on it.
48+
- Looping can be more legible for people unfamiliar with the trick.

kirovy/constants/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
CNCNET_INI_SECTION = "CnCNet"
1212
CNCNET_INI_MAP_ID_KEY = "ID"
13-
CNCNET_INI_PARENT_ID_KEY = "ParentID"
13+
CNCNET_INI_MAP_PARENT_ID_KEY = "ParentID"
1414

1515

1616
class CncnetUserGroup:

kirovy/constants/api_codes.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import enum
2+
3+
4+
class UploadApiCodes(enum.StrEnum):
5+
GAME_SLUG_DOES_NOT_EXIST = "game-slug-does-not-exist"
6+
MISSING_GAME_SLUG = "missing-game-slug"
7+
FILE_TO_LARGE = "file-too-large"
8+
EMPTY_UPLOAD = "where-file"
9+
DUPLICATE_MAP = "duplicate-map"
10+
FILE_EXTENSION_NOT_SUPPORTED = "file-extension-not-supported"

kirovy/exception_handler.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from rest_framework import status
2+
from rest_framework.views import exception_handler
3+
4+
from kirovy.exceptions.view_exceptions import KirovyValidationError
5+
from kirovy.objects import ui_objects
6+
from kirovy.response import KirovyResponse
7+
8+
9+
def kirovy_exception_handler(exception: Exception, context) -> KirovyResponse[ui_objects.ErrorResponseData] | None:
10+
"""Exception handler to deal with our custom exception types.
11+
12+
This gets called via the setting ``REST_FRAMEWORK['EXCEPTION_HANDLER']``.
13+
:attr:`kirovy.settings._base.REST_FRAMEWORK`
14+
15+
.. note::
16+
17+
`The DRF docs <https://www.django-rest-framework.org/api-guide/exceptions/#custom-exception-handling>`_
18+
19+
:param exception:
20+
The raised exception.
21+
:param context:
22+
:return:
23+
Returns the ``KirovyResponse`` if the exception is one we defined.
24+
Otherwise, it calls the base DRF exception handler :func:`rest_framework.views.exception_handler`.
25+
"""
26+
if isinstance(exception, KirovyValidationError):
27+
return KirovyResponse(exception.as_error_response_data(), status=status.HTTP_400_BAD_REQUEST)
28+
29+
return exception_handler(exception, context)

kirovy/exceptions/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class InvalidMimeType(ValidationError):
5353
class InvalidMapFile(ValidationError):
5454
"""Raised when a map can't be parsed or if it's missing a header."""
5555

56+
# TODO: Unify with kirovy.exceptions.view_exceptions.KirovyValidationError
5657
pass
5758

5859

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from rest_framework import status
2+
from rest_framework.exceptions import APIException as _DRFAPIException
3+
from django.utils.translation import gettext_lazy as _
4+
5+
from kirovy import typing as _t
6+
from kirovy.objects import ui_objects
7+
8+
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"
20+
additional: _t.DictStrAny | None = None
21+
code: str | None
22+
detail: str | None
23+
24+
def __init__(self, detail: str | None = None, code: str | None = None, additional: _t.DictStrAny | None = None):
25+
super().__init__(detail=detail, code=code)
26+
self.code = str(code) if code else self.default_code
27+
self.detail = str(detail) if detail else self.default_detail
28+
self.additional = additional
29+
30+
def as_error_response_data(self) -> ui_objects.ErrorResponseData:
31+
return ui_objects.ErrorResponseData(message=self.detail, code=self.code, additional=self.additional)

kirovy/logging.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from structlog import get_logger
2+
import orjson
3+
import typing as t
4+
5+
6+
def default_json_encode_object(value: object) -> str:
7+
json_func: t.Callable[[object], str] | None = getattr(value, "__json__", None)
8+
if json_func and callable(json_func):
9+
return json_func(value)
10+
11+
stringy: bool = type(value).__str__ is not object.__str__ # Check if this object implements __str__
12+
if stringy:
13+
return str(value)
14+
15+
return f"cannot-json-encode--{type(value).__name__}"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 4.2.20 on 2025-03-10 06:47
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("kirovy", "0009_mappreview"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="cncmapfile",
15+
name="hash_sha1",
16+
field=models.CharField(max_length=50, null=True),
17+
),
18+
migrations.AddField(
19+
model_name="mappreview",
20+
name="hash_sha1",
21+
field=models.CharField(max_length=50, null=True),
22+
),
23+
]

kirovy/models/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import typing
22

3-
from django.db.models import UUIDField, Model
3+
from django.db.models import Model
44

55
from .cnc_game import CncGame, CncFileExtension
66
from .cnc_map import CncMap, CncMapFile, MapCategory
@@ -10,5 +10,4 @@
1010

1111

1212
class SupportsBan(typing.Protocol):
13-
def set_ban(self, is_banned: bool, banned_by: CncUser) -> None:
14-
...
13+
def set_ban(self, is_banned: bool, banned_by: CncUser) -> None: ...

kirovy/models/cnc_game.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@ def is_valid_extension(extension: str) -> None:
1717
Raised for invalid file extension strings.
1818
"""
1919
if not extension.isalnum():
20-
raise exceptions.InvalidFileExtension(
21-
f'"{extension}" is not a valid file extension. Must be alpha only.'
22-
)
20+
raise exceptions.InvalidFileExtension(f'"{extension}" is not a valid file extension. Must be alpha only.')
2321

2422

2523
class CncFileExtension(CncNetBaseModel):
@@ -45,9 +43,7 @@ class ExtensionTypes(models.TextChoices):
4543
IMAGE = "image", "image"
4644
"""This file extension represents some kind of image uploaded by a user to display on the website."""
4745

48-
extension = models.CharField(
49-
max_length=32, unique=True, validators=[is_valid_extension], blank=False
50-
)
46+
extension = models.CharField(max_length=32, unique=True, validators=[is_valid_extension], blank=False)
5147
"""The actual file extension. Case insensitive but ``.lower()`` will be called all over."""
5248

5349
about = models.CharField(max_length=2048, null=True, blank=False)
@@ -61,9 +57,8 @@ class ExtensionTypes(models.TextChoices):
6157
)
6258

6359
def save(self, *args, **kwargs):
64-
is_valid_extension(
65-
self.extension
66-
) # force validator on save instead from a view.
60+
self.extension = self.extension.lower() # Force lowercase
61+
is_valid_extension(self.extension) # force validator on save instead from a view.
6762
super().save(*args, **kwargs)
6863

6964
@property
@@ -94,9 +89,7 @@ class CncGame(CncNetBaseModel):
9489
Does not affect temporary uploads via the multiplayer lobby.
9590
"""
9691

97-
compatible_with_parent_maps = models.BooleanField(
98-
default=False, null=False, blank=False
99-
)
92+
compatible_with_parent_maps = models.BooleanField(default=False, null=False, blank=False)
10093
"""If true then the maps from the parent game work in this game. e.g. RA2 maps work in YR."""
10194

10295
parent_game = models.ForeignKey("self", models.PROTECT, null=True, default=None)

0 commit comments

Comments
 (0)