Skip to content

Commit 399bbe2

Browse files
authored
feat: Screenshot model, legacy upload page, and map details (#21)
- Fix serializer crash when context not provided - Fix base map serializer. - Allow unknown UTF characters in map files for legacy db - Move django pycharm debugger to its own docker compose - Legacy manual upload emulator - Screenshot migration.
1 parent 1adcdf7 commit 399bbe2

16 files changed

+387
-40
lines changed

docker-compose.debugger.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# A copy of the docker compose, specifically for debuggers.
2+
# Will not run django by default, your debugger needs to run ``manage.py``.
3+
services:
4+
db:
5+
container_name: mapdb-postgres-debugger
6+
image: postgres
7+
volumes:
8+
- ${POSTGRES_DATA_DIR}/debugger-db/:/var/lib/postgresql/data
9+
environment:
10+
- POSTGRES_DB=${POSTGRES_DB}
11+
- POSTGRES_USER=${POSTGRES_USER}
12+
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
13+
env_file:
14+
- .env
15+
ports:
16+
- "127.0.0.1:${POSTGRES_PORT}:${POSTGRES_PORT}"
17+
command: -p ${POSTGRES_PORT}
18+
19+
debugger-django:
20+
container_name: mapdb-django-debugger
21+
build:
22+
context: .
23+
dockerfile: docker/Dockerfile
24+
target: debugger
25+
volumes:
26+
- .:/cncnet-map-api
27+
- ${HOST_MEDIA_ROOT}:/data/cncnet_silo # django will save user-uploaded files here. MEDIA_ROOT
28+
- ${HOST_STATIC_ROOT}:/data/cncnet_static # django will gather static files here. STATIC_ROOT
29+
- ./data/tmp:/tmp/pytest-of-root # For inspecting files during pytests
30+
ports:
31+
- "8000:8000"
32+
env_file:
33+
- .env
34+
environment:
35+
POSTGRES_TEST_HOST: db # Necessary to connect to docker db. Overrides the .env setting.
36+
depends_on:
37+
- db

docker-compose.yml

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,22 +43,3 @@ services:
4343
- "80:80"
4444
depends_on:
4545
- django
46-
47-
debugger:
48-
# Use this for launching via a debugger like PyCharm or VSCode.
49-
# Just builds, and doesn't execute anything, your debugger will be in charge of executing.
50-
build:
51-
context: .
52-
dockerfile: docker/Dockerfile
53-
target: debugger
54-
volumes:
55-
- .:/cncnet-map-api
56-
- ./data/tmp:/tmp/pytest-of-root # For inspecting files during pytests
57-
env_file:
58-
- .env
59-
environment:
60-
POSTGRES_TEST_HOST: mapdb-postgres-dev # Necessary to connect to docker db. Overrides the .env setting.
61-
# ports:
62-
# - "80:80"
63-
depends_on:
64-
- db

kirovy/constants/api_codes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class UploadApiCodes(enum.StrEnum):
99
EMPTY_UPLOAD = "where-file"
1010
DUPLICATE_MAP = "duplicate-map"
1111
FILE_EXTENSION_NOT_SUPPORTED = "file-extension-not-supported"
12+
INVALID = "invalid-data-upload"
1213

1314

1415
class LegacyUploadApiCodes(enum.StrEnum):
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Generated by Django 4.2.21 on 2025-05-19 17:59
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
import kirovy.models.file_base
7+
import uuid
8+
9+
10+
class Migration(migrations.Migration):
11+
12+
dependencies = [
13+
("kirovy", "0013_cncmap_is_mapdb1_compatible_alter_cncmapfile_file"),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="CncMapImageFile",
19+
fields=[
20+
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
21+
("created", models.DateTimeField(auto_now_add=True, null=True)),
22+
("modified", models.DateTimeField(auto_now=True, null=True)),
23+
("name", models.CharField(max_length=255)),
24+
("file", models.FileField(upload_to=kirovy.models.file_base._generate_upload_to)),
25+
("hash_md5", models.CharField(max_length=32)),
26+
("hash_sha512", models.CharField(max_length=512)),
27+
("hash_sha1", models.CharField(max_length=50, null=True)),
28+
("width", models.IntegerField()),
29+
("height", models.IntegerField()),
30+
("version", models.IntegerField(editable=False)),
31+
("cnc_game", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="kirovy.cncgame")),
32+
("cnc_map", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="kirovy.cncmap")),
33+
(
34+
"file_extension",
35+
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="kirovy.cncfileextension"),
36+
),
37+
(
38+
"last_modified_by",
39+
models.ForeignKey(
40+
null=True,
41+
on_delete=django.db.models.deletion.SET_NULL,
42+
related_name="modified_%(class)s_set",
43+
to=settings.AUTH_USER_MODEL,
44+
),
45+
),
46+
],
47+
options={
48+
"abstract": False,
49+
},
50+
),
51+
]

kirovy/models/cnc_map.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,49 @@ def generate_upload_to(instance: "CncMapFile", filename: str) -> pathlib.Path:
227227

228228
# e.g. "yr/maps/CNC_NET_MAP_ID_HEX/ra2_CNC_NET_MAP_ID_HEX_v1.map
229229
return pathlib.Path(instance.cnc_map.get_map_directory_path(), final_file_name)
230+
231+
232+
class CncMapImageFile(file_base.CncNetFileBaseModel):
233+
"""Represents an image file to display on the website for a map.
234+
235+
.. warning::
236+
237+
``name`` is auto-generated for this file subclass.
238+
"""
239+
240+
objects = CncMapFileManager()
241+
242+
width = models.IntegerField()
243+
height = models.IntegerField()
244+
version = models.IntegerField(editable=False)
245+
246+
cnc_map = models.ForeignKey(CncMap, on_delete=models.CASCADE, null=False)
247+
248+
ALLOWED_EXTENSION_TYPES = {game_models.CncFileExtension.ExtensionTypes.IMAGE.value}
249+
250+
UPLOAD_TYPE = settings.CNC_MAP_DIRECTORY
251+
252+
def save(self, *args, **kwargs):
253+
super().save(*args, **kwargs)
254+
255+
@staticmethod
256+
def generate_upload_to(instance: "CncMapFile", filename: str) -> pathlib.Path:
257+
"""Generate the path to upload map files to.
258+
259+
Gets called by :func:`kirovy.models.file_base._generate_upload_to` when ``CncMapImageFile.save`` is called.
260+
See [the django docs for file fields](https://docs.djangoproject.com/en/5.0/ref/models/fields/#filefield).
261+
``upload_to`` is set in :attr:`kirovy.models.file_base.CncNetFileBaseModel.file`, which calls
262+
``_generate_upload_to``, which calls this function.
263+
264+
:param instance:
265+
Acts as ``self``. The image file object that we are creating an upload path for.
266+
:param filename:
267+
The filename of the uploaded image file.
268+
:return:
269+
Path to upload map to relative to :attr:`~kirovy.settings.base.MEDIA_ROOT`.
270+
"""
271+
filename = pathlib.Path(filename)
272+
final_file_name = f"{instance.name}{filename.suffix}"
273+
274+
# e.g. "yr/maps/CNC_NET_MAP_ID_HEX/screenshot_of_map.jpg
275+
return pathlib.Path(instance.cnc_map.get_map_directory_path(), final_file_name)

kirovy/serializers/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def get_fields(self):
3030
"""
3131
fields = super().get_fields()
3232
request: t.Optional[KirovyRequest] = self.context.get("request")
33-
if not all([request, request.user.is_authenticated, request.user.is_staff]):
33+
if not (request and request.user.is_authenticated and request.user.is_staff):
3434
fields.pop("last_modified_by_id", None)
3535
return fields
3636

kirovy/serializers/cnc_map_serializers.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ class Meta:
8181
hash_sha512 = serializers.CharField(required=True, allow_blank=False)
8282
hash_sha1 = serializers.CharField(required=True, allow_blank=False)
8383

84-
def create(self, validated_data: t) -> cnc_map.CncMapFile:
84+
def create(self, validated_data: t.DictStrAny) -> cnc_map.CncMapFile:
8585
map_file = cnc_map.CncMapFile(**validated_data)
8686
map_file.save()
8787
return map_file
@@ -106,7 +106,7 @@ class CncMapBaseSerializer(CncNetUserOwnedModelSerializer):
106106
description = serializers.CharField(
107107
required=True,
108108
allow_null=False,
109-
allow_blank=False,
109+
allow_blank=True,
110110
trim_whitespace=True,
111111
min_length=10,
112112
)
@@ -117,11 +117,9 @@ class CncMapBaseSerializer(CncNetUserOwnedModelSerializer):
117117
)
118118
category_ids = serializers.PrimaryKeyRelatedField(
119119
source="categories",
120-
queryset=cnc_map.MapCategory.objects.all(),
121120
pk_field=serializers.UUIDField(),
122121
many=True,
123-
allow_null=False,
124-
allow_empty=False,
122+
read_only=True, # Set it manually.
125123
)
126124
is_published = serializers.BooleanField(
127125
default=False,
@@ -139,9 +137,27 @@ class CncMapBaseSerializer(CncNetUserOwnedModelSerializer):
139137
legacy_upload_date = serializers.DateTimeField(
140138
read_only=True,
141139
)
140+
incomplete_upload = serializers.BooleanField(
141+
default=False,
142+
)
143+
144+
parent_id = serializers.PrimaryKeyRelatedField(
145+
source="parent",
146+
queryset=cnc_map.CncMap.objects.all(),
147+
pk_field=serializers.UUIDField(),
148+
many=False,
149+
allow_null=True,
150+
allow_empty=False,
151+
default=None,
152+
)
142153

143154
class Meta:
144155
model = cnc_map.CncMap
145156
# We return the ID instead of the whole object.
146-
exclude = ["cnc_game", "categories"]
157+
exclude = ["cnc_game", "categories", "parent"]
147158
fields = "__all__"
159+
160+
def create(self, validated_data: t.DictStrAny) -> cnc_map.CncMap:
161+
cnc_map_instance = cnc_map.CncMap(**validated_data)
162+
cnc_map_instance.save()
163+
return cnc_map_instance

kirovy/services/cnc_gen_2_services.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def read_django_file(self, file: File):
6060
# We can't use ConfigParser.read_file because parser expects the file to be read as a string,
6161
# but django uploaded files are read as bytes. So we need to convert to string first.
6262
# If `decode` is crashing in a test, make sure your test file is read in read-mode "rb".
63-
self.read_string(file.read().decode())
63+
self.read_string(file.read().decode(errors="ignore"))
6464
except configparser.ParsingError as e:
6565
raise exceptions.InvalidMapFile(
6666
ParseErrorMsg.CORRUPT_MAP,

kirovy/settings/_base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@
169169
"""
170170
str: The directory inside of :attr:`~kirovy.settings._base.STATIC_URL` where we store game-specific and mod-specific
171171
logos and backgrounds. So a Red Alert 2 icon would be in e.g. ``URL/static/game_images/ra2/icons/allies.png``
172+
173+
.. warning::
174+
175+
This is **not** where we store user-uploaded images. Do not store them here.
172176
"""
173177

174178

kirovy/templates/__init__.py

Whitespace-only changes.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>CncNet Test Map Upload</title>
7+
{% load static %}
8+
<link rel="stylesheet" type="text/css" href="{% static 'bc-assets/bc-main.css' %}">
9+
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600&amp;display=swap" rel="stylesheet">
10+
<link rel="icon" type="image/png" sizes="32x32" href="https://cncnet.org/favicon-32x32.png">
11+
</head>
12+
<body>
13+
<div id="flex-main">
14+
<div id="flex-container">
15+
<div id="header">
16+
<a class="navbar-brand" href="http://cncnet.org" title="CnCNet Home">
17+
<img src="https://cncnet.org/build/assets/logo-ad41e578.svg" alt="CnCNet logo" loading="lazy" class="logo-full">
18+
<span class="logo-tagline">
19+
Keeping C&amp;C Alive Since 2009
20+
</span>
21+
</a>
22+
<h1>CnCNet 5 client upload test form</h1>
23+
</div>
24+
<div id="content">
25+
<p>
26+
Select a <span class="inline-code"><span class="it">&lt;sha1&gt;</span>.zip</span> from your file system.
27+
It should contain the <span class="inline-code">.mpr</span> file for
28+
Red Alert or <span class="inline-code">.ini</span> and
29+
<span class="inline-code">.bin</span> for Tiberian Dawn.
30+
</p>
31+
<p>
32+
The archive will then be extracted, validated and rebuilt for storage.
33+
You will receive a <span class="inline-code">200 OK</span> status code if your
34+
uploaded file was valid.
35+
</p>
36+
<form action="/upload" method="post" enctype="multipart/form-data">
37+
<label hidden="hidden" for="game-slug">Game</label>
38+
<select id="game-slug" name="game">
39+
<option value="td" selected="selected">Command &amp; Conquer (Tiberian Dawn)</option>
40+
<option value="ra" selected="selected">Red Alert 1</option>
41+
<option value="d2" selected="selected">Dune 2000</option>
42+
<option value="ts" selected="selected">Tiberian Sun</option>
43+
<option value="yr" selected="selected">Yuri's Revenge</option>
44+
</select>
45+
<label hidden="hidden" for="map-file-input">Map zip file</label>
46+
<input id="map-file-input" type="file" name="file" accept="application/zip">
47+
<button type="submit">Upload</button>
48+
</form>
49+
<p>The CnCNet client will function <strong>exactly</strong> like this form.</p>
50+
</div>
51+
<div id="footer">
52+
<a href="https://www.digitalocean.com/?refcode=337544e2ec7b&amp;utm_campaign=Referral_Invite&amp;utm_medium=opensource&amp;utm_source=CnCNet" title="Powered by Digital Ocean" target="_blank">
53+
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" width="201px" alt="Powered By Digital Ocean">
54+
</a>
55+
</div>
56+
</div>
57+
</div>
58+
</body>
59+
</html>

kirovy/urls.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,18 @@
1818
from django.conf.urls.static import static
1919
from django.contrib import admin
2020
from django.db import connection
21-
from django.urls import path, include
21+
from django.urls import path, include, URLPattern, URLResolver
2222
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
2323

2424
from kirovy.models import CncGame
2525
from kirovy.settings import settings_constants
2626
from kirovy.views import test, cnc_map_views, permission_views, admin_views, map_upload_views
2727
from kirovy import typing as t, constants
2828

29+
_DjangoPath = URLPattern | URLResolver
2930

30-
def _get_games_url_patterns() -> list[path]:
31+
32+
def _get_games_url_patterns() -> list[_DjangoPath]:
3133
"""Return URLs compatible with legacy CnCNet clients.
3234
3335
- URLs are loaded when the :mod:`kirovy.urls` module is loaded, which happens when Django starts.
@@ -48,6 +50,7 @@ def _get_games_url_patterns() -> list[path]:
4850
return []
4951

5052
return [
53+
path("upload-manual", cnc_map_views.MapLegacyStaticUI.as_view()),
5154
path("upload", map_upload_views.CncNetBackwardsCompatibleUploadView.as_view()),
5255
*(
5356
# Make e.g. /yr/map_hash, /ra2/map_hash, etc
@@ -61,7 +64,7 @@ def _get_games_url_patterns() -> list[path]:
6164
]
6265

6366

64-
def _get_url_patterns() -> list[path]:
67+
def _get_url_patterns() -> list[_DjangoPath]:
6568
"""Return the root level url patterns.
6669
6770
I added this because I wanted to have the root URLs at the top of the file,

0 commit comments

Comments
 (0)