Skip to content

Commit 2ce9b6f

Browse files
authored
feat: Mvp for image upload saving (#7)
* feat: Mvp for image upload saving Save extracted images, and make models for map previews * feat: Map previews Make development server serve files * feat: Map details - Implement map detail view - Better docs for permissions regarding editing - Check for object ban status in `CanEdit` permissions so that banned objects can't be edited by the offender - Fix pytest debugging by adding `setuptools` as a req. - Test that an uploaded map can be retrieved via the API
1 parent 3c418ff commit 2ce9b6f

File tree

20 files changed

+453
-44
lines changed

20 files changed

+453
-44
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
run: cp ci.env .env
1919

2020
- name: Build docker images
21-
run: docker-compose build
21+
run: docker compose build
2222

2323
- name: Run PyTest
24-
run: docker-compose run test
24+
run: docker compose run test

docs/file_uploads.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# File uploads
2+
3+
All file uploads should go into the `kirovy.models.file_base.CncNetFileBaseModel` class.
4+
5+
This class uses Django's [upload_to](https://docs.djangoproject.com/en/5.0/ref/models/fields/#django.db.models.FileField.upload_to)
6+
logic to automatically place the files. By default, files will go to:
7+
8+
- `{seetings.MEDIA_ROOT}/{game_slug}/{object.UPLOAD_TYPE}/{object.id}/filename.ext`
9+
10+
An example of a default upload path would be:
11+
12+
- `/uploaded_media/yr/uncategorized_uploads/1234/conscript_sprites.shf`
13+
14+
## Customizing the upload path for a subclass
15+
16+
Controlling where a file is saved can be easily done by changing `UPLOAD_TYPE: str` for the subclass.
17+
The default value is `uncategorized_uploads`.
18+
19+
If you need even more control, then override `kirovy.models.file_base.CncNetFileBaseModel.generate_upload_to` with your
20+
own function. Files will still always be placed in `settings.MEDIA_ROOT`, but `generate_upload_to` can control
21+
everything about the upload path after that application-wide root path.

kirovy/migrations/0007_jpg_png.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Generated by Django 4.2.11 on 2024-08-15 03:35
2+
3+
from django.db import migrations
4+
from django.db.backends.postgresql.schema import DatabaseSchemaEditor
5+
from django.db.migrations.state import StateApps
6+
7+
from kirovy import typing
8+
from kirovy.models import CncFileExtension as _Ext, CncUser as _User
9+
10+
11+
def _forward(apps: StateApps, schema_editor: DatabaseSchemaEditor):
12+
13+
# This is necessary in case later migrations make schema changes to these models.
14+
# Importing them normally will use the latest schema state and will crash if those
15+
# migrations are after this one.
16+
CncFileExtension: typing.Type[_Ext] = apps.get_model("kirovy", "CncFileExtension")
17+
CncUser: typing.Type[_User] = apps.get_model("kirovy", "CncUser")
18+
19+
migration_user = CncUser.objects.get_or_create_migration_user()
20+
21+
jpg = CncFileExtension(
22+
extension="jpg",
23+
extension_type=_Ext.ExtensionTypes.IMAGE.value,
24+
about="Jpg files are used for previews on the website and in the client.",
25+
last_modified_by_id=migration_user.id,
26+
)
27+
jpg.save()
28+
29+
jpeg = CncFileExtension(
30+
extension="jpeg",
31+
extension_type=_Ext.ExtensionTypes.IMAGE.value,
32+
about="Jpeg files are used for previews on the website and in the client.",
33+
last_modified_by_id=migration_user.id,
34+
)
35+
jpeg.save()
36+
37+
png = CncFileExtension(
38+
extension="png",
39+
extension_type=_Ext.ExtensionTypes.IMAGE.value,
40+
about="PNG files are used for previews on the website and in the client.",
41+
last_modified_by_id=migration_user.id,
42+
)
43+
png.save()
44+
45+
46+
def _backward(apps: StateApps, schema_editor: DatabaseSchemaEditor):
47+
"""Deleting the games on accident could be devastating to the db so no."""
48+
pass
49+
50+
51+
class Migration(migrations.Migration):
52+
53+
dependencies = [
54+
("kirovy", "0006_cncmap_parent"),
55+
]
56+
57+
operations = [
58+
migrations.RunPython(_forward, reverse_code=_backward, elidable=False),
59+
]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 4.2.11 on 2024-08-15 03:54
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("kirovy", "0007_jpg_png"),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name="cncfileextension",
15+
name="extension_type",
16+
field=models.CharField(
17+
choices=[("map", "map"), ("assets", "assets"), ("image", "image")],
18+
max_length=32,
19+
),
20+
),
21+
]
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Generated by Django 4.2.11 on 2024-08-15 04:00
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", "0008_alter_cncfileextension_extension_type"),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="MapPreview",
19+
fields=[
20+
(
21+
"id",
22+
models.UUIDField(
23+
default=uuid.uuid4,
24+
editable=False,
25+
primary_key=True,
26+
serialize=False,
27+
),
28+
),
29+
("created", models.DateTimeField(auto_now_add=True, null=True)),
30+
("modified", models.DateTimeField(auto_now=True, null=True)),
31+
("name", models.CharField(max_length=255)),
32+
(
33+
"file",
34+
models.FileField(
35+
upload_to=kirovy.models.file_base._generate_upload_to
36+
),
37+
),
38+
("hash_md5", models.CharField(max_length=32)),
39+
("hash_sha512", models.CharField(max_length=512)),
40+
("is_extracted", models.BooleanField()),
41+
(
42+
"cnc_game",
43+
models.ForeignKey(
44+
on_delete=django.db.models.deletion.PROTECT, to="kirovy.cncgame"
45+
),
46+
),
47+
(
48+
"cnc_map_file",
49+
models.ForeignKey(
50+
on_delete=django.db.models.deletion.CASCADE,
51+
to="kirovy.cncmapfile",
52+
),
53+
),
54+
(
55+
"file_extension",
56+
models.ForeignKey(
57+
on_delete=django.db.models.deletion.PROTECT,
58+
to="kirovy.cncfileextension",
59+
),
60+
),
61+
(
62+
"last_modified_by",
63+
models.ForeignKey(
64+
null=True,
65+
on_delete=django.db.models.deletion.SET_NULL,
66+
related_name="modified_%(class)s_set",
67+
to=settings.AUTH_USER_MODEL,
68+
),
69+
),
70+
],
71+
options={
72+
"abstract": False,
73+
},
74+
),
75+
]

kirovy/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
from .cnc_map import CncMap, CncMapFile, MapCategory
33
from .cnc_user import CncUser
44
from .file_base import CncNetFileBaseModel
5+
from .map_preview import MapPreview

kirovy/models/cnc_game.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ class CncFileExtension(CncNetBaseModel):
2626
"""File extension types for Command & Conquer games and what they do.
2727
2828
Useful page: https://modenc.renegadeprojects.com/File_Types
29+
30+
.. note::
31+
32+
These extension objects are only necessary for user-uploaded files. Don't worry about all of this
33+
overhead for any files committed to the repository.
2934
"""
3035

3136
class ExtensionTypes(models.TextChoices):
@@ -37,6 +42,9 @@ class ExtensionTypes(models.TextChoices):
3742
ASSETS = "assets", "assets"
3843
"""This file extension represents some kind of game asset to support a map, e.g. a ``.mix`` file."""
3944

45+
IMAGE = "image", "image"
46+
"""This file extension represents some kind of image uploaded by a user to display on the website."""
47+
4048
extension = models.CharField(
4149
max_length=32, unique=True, validators=[is_valid_extension], blank=False
4250
)

kirovy/models/cnc_map.py

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,6 @@ class CncMap(cnc_user.CncNetUserOwnedModel):
6262

6363
map_name = models.CharField(max_length=128, null=False, blank=False)
6464
description = models.CharField(max_length=4096, null=False, blank=False)
65-
cnc_game = models.ForeignKey(game_models.CncGame, models.PROTECT, null=False)
66-
categories = models.ManyToManyField(MapCategory)
6765
is_legacy = models.BooleanField(
6866
default=False,
6967
help_text="If true, this is an upload from the old cncnet database.",
@@ -115,6 +113,8 @@ class CncMap(cnc_user.CncNetUserOwnedModel):
115113
help_text="If true, then the map file has been uploaded, but the map info has not been set yet.",
116114
)
117115

116+
cnc_game = models.ForeignKey(game_models.CncGame, models.PROTECT, null=False)
117+
categories = models.ManyToManyField(MapCategory)
118118
parent = models.ForeignKey(
119119
"CncMap",
120120
on_delete=models.SET_NULL,
@@ -184,20 +184,9 @@ class Meta:
184184
def save(self, *args, **kwargs):
185185
if not self.version:
186186
self.version = self.cnc_map.next_version_number()
187+
self.name = self.cnc_map.generate_versioned_name_for_file()
187188
super().save(*args, **kwargs)
188189

189-
def get_map_upload_path(self, filename: str) -> pathlib.Path:
190-
"""Generate the upload path for the map file.
191-
192-
:param filename:
193-
The filename that the user uploaded.
194-
:return:
195-
Path to store the map file in.
196-
This path is not guaranteed to exist because we use this function on first-save.
197-
"""
198-
directory = self.cnc_map.get_map_directory_path()
199-
return pathlib.Path(directory, filename)
200-
201190
@staticmethod
202191
def generate_upload_to(instance: "CncMapFile", filename: str) -> pathlib.Path:
203192
"""Generate the path to upload map files to.

kirovy/models/file_base.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ class Meta:
4747
)
4848
"""What type of file extension this object is."""
4949

50-
ALLOWED_EXTENSION_TYPES = set(game_models.CncFileExtension.ExtensionTypes.values)
50+
ALLOWED_EXTENSION_TYPES: t.Set[str] = set(
51+
game_models.CncFileExtension.ExtensionTypes.values
52+
)
5153
"""Used to make sure e.g. a ``.mix`` doesn't get uploaded as a ``CncMapFile``.
5254
5355
These are checked against :attr:`kirovy.models.cnc_game.CncFileExtension.extension_type`.
@@ -67,7 +69,15 @@ class Meta:
6769
def validate_file_extension(
6870
self, file_extension: game_models.CncFileExtension
6971
) -> None:
70-
if file_extension.extension.lower() not in self.cnc_game.allowed_extensions_set:
72+
# Images are allowed for all games.
73+
is_image = (
74+
self.file_extension.extension_type
75+
== self.file_extension.ExtensionTypes.IMAGE
76+
)
77+
is_allowed_for_game = (
78+
file_extension.extension.lower() in self.cnc_game.allowed_extensions_set
79+
)
80+
if not is_allowed_for_game and not is_image:
7181
raise validators.ValidationError(
7282
f'"{file_extension.extension}" is not a valid file extension for game "{self.cnc_game.full_name}".'
7383
)

kirovy/models/map_preview.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import pathlib
2+
import uuid
3+
4+
from django.db import models
5+
6+
from kirovy import typing as t
7+
from kirovy.models import cnc_map, file_base, cnc_user, cnc_game
8+
9+
10+
class MapPreview(file_base.CncNetFileBaseModel):
11+
"""An image preview for a C&C Map upload.
12+
13+
.. note::
14+
15+
This class has no user link. The link to the user is via
16+
:attr:`kirovy.models.map_preview.CncMapPreview.cnc_map`.
17+
18+
.. note::
19+
20+
Map previews are uploaded to the same directory as map files using
21+
:func:`kirovy.models.cnc_map.CncMap.get_map_directory_path` through this class's
22+
:attr:`kirovy.models.map_preview.CncMapPreview.cnc_map`.
23+
24+
"""
25+
26+
cnc_map_file = models.ForeignKey(cnc_map.CncMapFile, on_delete=models.CASCADE)
27+
"""The map file that this preview belongs to. We link to the file so that we can have version-specific previews."""
28+
29+
ALLOWED_EXTENSION_TYPES = {
30+
cnc_game.CncFileExtension.ExtensionTypes.IMAGE.value,
31+
}
32+
33+
is_extracted = models.BooleanField(null=False, blank=False)
34+
"""If true, then this image was extracted from the uploaded map file, usually generated by FinalAlert.
35+
36+
This will always be false for games released after Yuri's Revenge because Generals and beyond do not pack the
37+
preview image into the map files.
38+
"""
39+
40+
def save(self, *args, **kwargs):
41+
self.name = self.cnc_map_file.name
42+
self.cnc_game = self.cnc_map_file.cnc_game
43+
return super().save(*args, **kwargs)
44+
45+
@staticmethod
46+
def generate_upload_to(instance: "MapPreview", filename: str) -> pathlib.Path:
47+
"""Generate the path to upload map previews to.
48+
49+
Gets called by :func:`kirovy.models.file_base._generate_upload_to` when ``CncMapFile.save`` is called.
50+
See [the django docs for file fields](https://docs.djangoproject.com/en/5.0/ref/models/fields/#filefield).
51+
``upload_to`` is set in :attr:`kirovy.models.file_base.CncNetFileBaseModel.file`, which calls
52+
``_generate_upload_to``, which calls this function.
53+
54+
:param instance:
55+
Acts as ``self``. The map preview object that we are creating an upload path for.
56+
:param filename:
57+
The filename of the uploaded file.
58+
:return:
59+
Path to upload map to relative to :attr:`~kirovy.settings.base.MEDIA_ROOT`.
60+
"""
61+
filename = pathlib.Path(filename)
62+
filename_uuid = str(uuid.uuid4()).replace("-", "")
63+
final_file_name = f"{instance.name}_{filename_uuid}{filename.suffix}"
64+
65+
# e.g. "yr/maps/CNC_NET_MAP_ID_HEX/ra2_map_file_name_UUID.png
66+
return pathlib.Path(
67+
instance.cnc_map_file.cnc_map.get_map_directory_path(), final_file_name
68+
)

0 commit comments

Comments
 (0)