Skip to content

Commit c75a353

Browse files
authored
feat: Legacy URL, api browser, fix build (#12)
- API browser - MapDB 1.0 backwards compatible endpoints - Test mermaid
1 parent 491a6c1 commit c75a353

18 files changed

+837
-32
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,4 @@ data/
165165
test.bmp
166166
# Photoshop files
167167
image-source/
168+
docs/.obsidian/

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,9 @@ stop:
1111
test:
1212
docker compose build django
1313
docker compose run test
14+
15+
16+
django-bash:
17+
# For running developer `./mange,py` commands in local dev.
18+
docker compose build django
19+
docker compose run django bash

docs/auth_and_permissions.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,10 @@ How permission is determined:
88
3. The function `kirovy.authentication.CncNetAuthentication.authenticate` is called, which will create or updates
99
the user object in Kirovy, then set `request.user` to that object.
1010
4. The permission classes check their various permissions based on `request.user`
11+
12+
13+
```mermaid
14+
graph LR;
15+
url[Found in *urls.py*, points to a view]
16+
view_base[Views have an *authentication_classes* attribute to define an authenticator. It defaults to *kirovy.authentication.CncNetAuthentication*]
17+
```

kirovy/constants/__init__.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ class GameSlugs(enum.StrEnum):
119119
cnc_reloaded = "cncr"
120120
rise_of_the_east = "rote"
121121
red_resurrection = "rr"
122-
dune_2000 = "d2k"
122+
dune_2000 = "d2"
123123
generals = "gen"
124124
zero_hour = "zh"
125125
battle_for_middle_earth = "bfme"
@@ -130,6 +130,19 @@ class GameSlugs(enum.StrEnum):
130130
red_alert_3_uprising = "ra3u"
131131

132132

133+
BACKWARDS_COMPATIBLE_GAMES = [
134+
GameSlugs.tiberian_dawn,
135+
GameSlugs.red_alert,
136+
GameSlugs.tiberian_sun,
137+
GameSlugs.dawn_of_the_tiberium_age,
138+
GameSlugs.yuris_revenge,
139+
GameSlugs.dune_2000,
140+
]
141+
"""attr: These are the games that MapDB 1.0 supported.
142+
We need to maintain backwards compatibility for clients we can't update.
143+
"""
144+
145+
133146
class GameEngines:
134147
"""Maps the game slugs to the general game engine.
135148
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 4.2.20 on 2025-03-17 20:32
2+
3+
from django.db import migrations, models
4+
import kirovy.models.file_base
5+
import kirovy.zip_storage
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
("kirovy", "0010_cncmapfile_hash_sha1_mappreview_hash_sha1"),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name="cncmapfile",
17+
name="file",
18+
field=models.FileField(
19+
storage=kirovy.zip_storage.ZipFileStorage, upload_to=kirovy.models.file_base._generate_upload_to
20+
),
21+
),
22+
]
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Generated by Django 4.2.20 on 2025-03-18 18:07
2+
3+
import typing
4+
5+
from django.db import migrations
6+
from django.db.backends.postgresql.schema import DatabaseSchemaEditor
7+
from django.db.migrations.state import StateApps
8+
9+
from kirovy.models import CncFileExtension as _Ext, CncUser as _User, CncGame as _Game
10+
11+
12+
def _forward(apps: StateApps, schema_editor: DatabaseSchemaEditor):
13+
14+
# This is necessary in case later migrations make schema changes to these models.
15+
# Importing them normally will use the latest schema state and will crash if those
16+
# migrations are after this one.
17+
CncFileExtension: typing.Type[_Ext] = apps.get_model("kirovy", "CncFileExtension")
18+
CncUser: typing.Type[_User] = apps.get_model("kirovy", "CncUser")
19+
CncGame: typing.Type[_Game] = apps.get_model("kirovy", "CncGame")
20+
21+
migration_user = CncUser.objects.get_or_create_migration_user()
22+
23+
zip_ext = CncFileExtension(
24+
extension="zip",
25+
extension_type=_Ext.ExtensionTypes.MAP.value,
26+
about="Raw zip uploads for backwards compatibility with clients CnCNet doesn't control.",
27+
last_modified_by_id=migration_user.id,
28+
)
29+
zip_ext.save()
30+
31+
for game in CncGame.objects.filter(is_visible=True):
32+
game.allowed_extensions.add(zip_ext)
33+
34+
35+
def _backward(apps: StateApps, schema_editor: DatabaseSchemaEditor):
36+
"""Deleting the extension on accident could be devastating to the db so no."""
37+
pass
38+
39+
40+
class Migration(migrations.Migration):
41+
42+
dependencies = [
43+
("kirovy", "0011_alter_cncmapfile_file"),
44+
]
45+
46+
# Elidable=false means that squashmigrations will not delete this.
47+
operations = [
48+
migrations.RunPython(_forward, reverse_code=_backward, elidable=False),
49+
]

kirovy/settings/_base.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
"django.contrib.staticfiles",
5050
"django_filters", # Used for advanced querying on the API.
5151
"rest_framework", # Django REST Framework.
52+
"drf_spectacular", # Generates openapi docs.
53+
"drf_spectacular_sidecar", # swagger assets for openapi
5254
]
5355

5456

@@ -87,6 +89,7 @@
8789
"kirovy.authentication.CncNetAuthentication",
8890
],
8991
"EXCEPTION_HANDLER": "kirovy.exception_handler.kirovy_exception_handler",
92+
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
9093
}
9194
"""
9295
attr: Define the default authentication backend for endpoints.
@@ -99,6 +102,17 @@
99102
are set in Django Rest Framework, see `The DRF docs <https://www.django-rest-framework.org/api-guide/permissions/>`_
100103
"""
101104

105+
SPECTACULAR_SETTINGS = {
106+
"TITLE": "Kirovy",
107+
"DESCRIPTION": "CnCNet Map API",
108+
"VERSION": "0.1.0",
109+
"SERVE_INCLUDE_SCHEMA": False,
110+
"SWAGGER_UI_DIST": "SIDECAR", # shorthand to use the sidecar instead
111+
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
112+
"REDOC_DIST": "SIDECAR",
113+
}
114+
115+
102116
# Database
103117
# https://docs.djangoproject.com/en/4.1/ref/settings/#databases
104118

@@ -208,5 +222,5 @@
208222
AUTH_USER_MODEL = "kirovy.CncUser"
209223

210224

211-
RUN_ENVIRONMENT = get_env_var("RUN_ENVIRONMENT", "dev")
225+
RUN_ENVIRONMENT = get_env_var("RUN_ENVIRONMENT", "prod")
212226
"""attr: Defines which type of environment we are running on. Useful for debug logic."""

kirovy/urls.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@
1818
from django.conf.urls.static import static
1919
from django.contrib import admin
2020
from django.urls import path, include
21+
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
2122

22-
import kirovy.views.map_upload_views
23-
from kirovy.views import test, cnc_map_views, permission_views, admin_views
24-
from kirovy import typing as t
23+
from kirovy.models import CncGame
24+
from kirovy.views import test, cnc_map_views, permission_views, admin_views, map_upload_views
25+
from kirovy import typing as t, constants
2526

2627

2728
def _get_url_patterns() -> list[path]:
@@ -30,17 +31,37 @@ def _get_url_patterns() -> list[path]:
3031
I added this because I wanted to have the root URLs at the top of the file,
3132
but I didn't want to have other url files.
3233
"""
34+
dev_urls = []
35+
if settings.RUN_ENVIRONMENT == "dev":
36+
dev_urls = [
37+
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
38+
# Optional UI:
39+
path("api/schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
40+
path("api/schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
41+
]
42+
43+
backwards_compatible_urls = [
44+
path("upload", map_upload_views.CncNetBackwardsCompatibleUploadView.as_view()),
45+
*(
46+
# Make e.g. /yr/map_hash, /ra2/map_hash, etc
47+
path(f"{g.slug}/<str:sha1_hash>", cnc_map_views.BackwardsCompatibleMapView.as_view(), {"game_id": g.id})
48+
for g in CncGame.objects.filter(slug__in=constants.BACKWARDS_COMPATIBLE_GAMES)
49+
),
50+
]
51+
3352
return (
3453
[
3554
path("admin/", include(admin_patterns)),
3655
path("test/jwt", test.TestJwt.as_view()),
3756
path("ui-permissions/", permission_views.ListPermissionForAuthUser.as_view()),
3857
path("maps/", include(map_patterns)),
3958
# path("users/<uuid:cnc_user_id>/", ...), # will show which files a user has uploaded.
40-
# path("games/", ...), # get games.
59+
# path("games/", ...), # get games.,
4160
]
42-
+ static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
43-
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
61+
+ backwards_compatible_urls
62+
+ static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) # static assets
63+
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) # user uploads
64+
+ dev_urls
4465
)
4566

4667

@@ -49,8 +70,8 @@ def _get_url_patterns() -> list[path]:
4970
# path("categories/", ...), # return all categories
5071
# path("categories/game/<uuid:cnc_game_id>/", ...),
5172
path("categories/", cnc_map_views.MapCategoryListCreateView.as_view()),
52-
path("upload/", kirovy.views.map_upload_views.MapFileUploadView.as_view()),
53-
path("client/upload/", kirovy.views.map_upload_views.CncnetClientMapUploadView.as_view()),
73+
path("upload/", map_upload_views.MapFileUploadView.as_view()),
74+
path("client/upload/", map_upload_views.CncnetClientMapUploadView.as_view()),
5475
path("<uuid:pk>/", cnc_map_views.MapRetrieveUpdateView.as_view()),
5576
path("delete/<uuid:pk>/", cnc_map_views.MapDeleteView.as_view()),
5677
path("search/", cnc_map_views.MapListCreateView.as_view()),
@@ -67,5 +88,4 @@ def _get_url_patterns() -> list[path]:
6788
# /admin/
6889
admin_patterns = [path("ban/", admin_views.BanView.as_view())]
6990

70-
7191
urlpatterns = _get_url_patterns()

kirovy/views/base_views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ def get_paginated_response(self, results: t.List[t.DictStrAny]) -> KirovyRespons
3737

3838
return KirovyResponse(data, status=status.HTTP_200_OK)
3939

40-
def get_paginated_response_schema(self, schema):
41-
raise NotImplementedError()
40+
# def get_paginated_response_schema(self, schema):
41+
# raise NotImplementedError()
4242

4343

4444
class KirovyListCreateView(_g.ListCreateAPIView):

kirovy/views/cnc_map_views.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
import logging
2+
from uuid import UUID
23

34
from django.db.models import Q, QuerySet
5+
from django.http import FileResponse
46
from rest_framework import status
57
from rest_framework.exceptions import PermissionDenied
68
from rest_framework.filters import SearchFilter, OrderingFilter
79
from django_filters import rest_framework as filters
10+
from rest_framework.permissions import AllowAny
11+
from rest_framework.views import APIView
812

913
from kirovy import permissions
1014
from kirovy.models import (
1115
MapCategory,
1216
CncGame,
1317
CncMap,
18+
CncMapFile,
1419
)
20+
from kirovy.response import KirovyResponse
1521
from kirovy.serializers import cnc_map_serializers
1622
from kirovy.views import base_views
1723

@@ -222,3 +228,17 @@ def perform_destroy(self, instance: CncMap):
222228
if instance.is_legacy:
223229
raise PermissionDenied("cannot-delete-legacy-maps", status.HTTP_403_FORBIDDEN)
224230
return super().perform_destroy(instance)
231+
232+
233+
class BackwardsCompatibleMapView(APIView):
234+
permission_classes = [AllowAny]
235+
236+
def get(self, request, sha1_hash: str, game_id: UUID, format=None):
237+
"""
238+
Return the map matching the hash, if it exists.
239+
"""
240+
map_file = CncMapFile.objects.filter(hash_sha1=sha1_hash, cnc_game_id=game_id).first()
241+
if not map_file:
242+
return KirovyResponse(status=status.HTTP_404_NOT_FOUND)
243+
244+
return FileResponse(map_file.file.open("rb"), as_attachment=True, filename=f"{map_file.hash_sha1}.zip")

0 commit comments

Comments
 (0)