Skip to content

Commit

Permalink
feat: Mvp for image upload saving (#7)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
alexlambson authored Oct 24, 2024
1 parent 3c418ff commit 2ce9b6f
Show file tree
Hide file tree
Showing 20 changed files with 453 additions and 44 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
run: cp ci.env .env

- name: Build docker images
run: docker-compose build
run: docker compose build

- name: Run PyTest
run: docker-compose run test
run: docker compose run test
21 changes: 21 additions & 0 deletions docs/file_uploads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# File uploads

All file uploads should go into the `kirovy.models.file_base.CncNetFileBaseModel` class.

This class uses Django's [upload_to](https://docs.djangoproject.com/en/5.0/ref/models/fields/#django.db.models.FileField.upload_to)
logic to automatically place the files. By default, files will go to:

- `{seetings.MEDIA_ROOT}/{game_slug}/{object.UPLOAD_TYPE}/{object.id}/filename.ext`

An example of a default upload path would be:

- `/uploaded_media/yr/uncategorized_uploads/1234/conscript_sprites.shf`

## Customizing the upload path for a subclass

Controlling where a file is saved can be easily done by changing `UPLOAD_TYPE: str` for the subclass.
The default value is `uncategorized_uploads`.

If you need even more control, then override `kirovy.models.file_base.CncNetFileBaseModel.generate_upload_to` with your
own function. Files will still always be placed in `settings.MEDIA_ROOT`, but `generate_upload_to` can control
everything about the upload path after that application-wide root path.
59 changes: 59 additions & 0 deletions kirovy/migrations/0007_jpg_png.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Generated by Django 4.2.11 on 2024-08-15 03:35

from django.db import migrations
from django.db.backends.postgresql.schema import DatabaseSchemaEditor
from django.db.migrations.state import StateApps

from kirovy import typing
from kirovy.models import CncFileExtension as _Ext, CncUser as _User


def _forward(apps: StateApps, schema_editor: DatabaseSchemaEditor):

# This is necessary in case later migrations make schema changes to these models.
# Importing them normally will use the latest schema state and will crash if those
# migrations are after this one.
CncFileExtension: typing.Type[_Ext] = apps.get_model("kirovy", "CncFileExtension")
CncUser: typing.Type[_User] = apps.get_model("kirovy", "CncUser")

migration_user = CncUser.objects.get_or_create_migration_user()

jpg = CncFileExtension(
extension="jpg",
extension_type=_Ext.ExtensionTypes.IMAGE.value,
about="Jpg files are used for previews on the website and in the client.",
last_modified_by_id=migration_user.id,
)
jpg.save()

jpeg = CncFileExtension(
extension="jpeg",
extension_type=_Ext.ExtensionTypes.IMAGE.value,
about="Jpeg files are used for previews on the website and in the client.",
last_modified_by_id=migration_user.id,
)
jpeg.save()

png = CncFileExtension(
extension="png",
extension_type=_Ext.ExtensionTypes.IMAGE.value,
about="PNG files are used for previews on the website and in the client.",
last_modified_by_id=migration_user.id,
)
png.save()


def _backward(apps: StateApps, schema_editor: DatabaseSchemaEditor):
"""Deleting the games on accident could be devastating to the db so no."""
pass


class Migration(migrations.Migration):

dependencies = [
("kirovy", "0006_cncmap_parent"),
]

operations = [
migrations.RunPython(_forward, reverse_code=_backward, elidable=False),
]
21 changes: 21 additions & 0 deletions kirovy/migrations/0008_alter_cncfileextension_extension_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 4.2.11 on 2024-08-15 03:54

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("kirovy", "0007_jpg_png"),
]

operations = [
migrations.AlterField(
model_name="cncfileextension",
name="extension_type",
field=models.CharField(
choices=[("map", "map"), ("assets", "assets"), ("image", "image")],
max_length=32,
),
),
]
75 changes: 75 additions & 0 deletions kirovy/migrations/0009_mappreview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Generated by Django 4.2.11 on 2024-08-15 04:00

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import kirovy.models.file_base
import uuid


class Migration(migrations.Migration):

dependencies = [
("kirovy", "0008_alter_cncfileextension_extension_type"),
]

operations = [
migrations.CreateModel(
name="MapPreview",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created", models.DateTimeField(auto_now_add=True, null=True)),
("modified", models.DateTimeField(auto_now=True, null=True)),
("name", models.CharField(max_length=255)),
(
"file",
models.FileField(
upload_to=kirovy.models.file_base._generate_upload_to
),
),
("hash_md5", models.CharField(max_length=32)),
("hash_sha512", models.CharField(max_length=512)),
("is_extracted", models.BooleanField()),
(
"cnc_game",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="kirovy.cncgame"
),
),
(
"cnc_map_file",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="kirovy.cncmapfile",
),
),
(
"file_extension",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="kirovy.cncfileextension",
),
),
(
"last_modified_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="modified_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
]
1 change: 1 addition & 0 deletions kirovy/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
from .cnc_map import CncMap, CncMapFile, MapCategory
from .cnc_user import CncUser
from .file_base import CncNetFileBaseModel
from .map_preview import MapPreview
8 changes: 8 additions & 0 deletions kirovy/models/cnc_game.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ class CncFileExtension(CncNetBaseModel):
"""File extension types for Command & Conquer games and what they do.
Useful page: https://modenc.renegadeprojects.com/File_Types
.. note::
These extension objects are only necessary for user-uploaded files. Don't worry about all of this
overhead for any files committed to the repository.
"""

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

IMAGE = "image", "image"
"""This file extension represents some kind of image uploaded by a user to display on the website."""

extension = models.CharField(
max_length=32, unique=True, validators=[is_valid_extension], blank=False
)
Expand Down
17 changes: 3 additions & 14 deletions kirovy/models/cnc_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,6 @@ class CncMap(cnc_user.CncNetUserOwnedModel):

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

cnc_game = models.ForeignKey(game_models.CncGame, models.PROTECT, null=False)
categories = models.ManyToManyField(MapCategory)
parent = models.ForeignKey(
"CncMap",
on_delete=models.SET_NULL,
Expand Down Expand Up @@ -184,20 +184,9 @@ class Meta:
def save(self, *args, **kwargs):
if not self.version:
self.version = self.cnc_map.next_version_number()
self.name = self.cnc_map.generate_versioned_name_for_file()
super().save(*args, **kwargs)

def get_map_upload_path(self, filename: str) -> pathlib.Path:
"""Generate the upload path for the map file.
:param filename:
The filename that the user uploaded.
:return:
Path to store the map file in.
This path is not guaranteed to exist because we use this function on first-save.
"""
directory = self.cnc_map.get_map_directory_path()
return pathlib.Path(directory, filename)

@staticmethod
def generate_upload_to(instance: "CncMapFile", filename: str) -> pathlib.Path:
"""Generate the path to upload map files to.
Expand Down
14 changes: 12 additions & 2 deletions kirovy/models/file_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ class Meta:
)
"""What type of file extension this object is."""

ALLOWED_EXTENSION_TYPES = set(game_models.CncFileExtension.ExtensionTypes.values)
ALLOWED_EXTENSION_TYPES: t.Set[str] = set(
game_models.CncFileExtension.ExtensionTypes.values
)
"""Used to make sure e.g. a ``.mix`` doesn't get uploaded as a ``CncMapFile``.
These are checked against :attr:`kirovy.models.cnc_game.CncFileExtension.extension_type`.
Expand All @@ -67,7 +69,15 @@ class Meta:
def validate_file_extension(
self, file_extension: game_models.CncFileExtension
) -> None:
if file_extension.extension.lower() not in self.cnc_game.allowed_extensions_set:
# Images are allowed for all games.
is_image = (
self.file_extension.extension_type
== self.file_extension.ExtensionTypes.IMAGE
)
is_allowed_for_game = (
file_extension.extension.lower() in self.cnc_game.allowed_extensions_set
)
if not is_allowed_for_game and not is_image:
raise validators.ValidationError(
f'"{file_extension.extension}" is not a valid file extension for game "{self.cnc_game.full_name}".'
)
Expand Down
68 changes: 68 additions & 0 deletions kirovy/models/map_preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import pathlib
import uuid

from django.db import models

from kirovy import typing as t
from kirovy.models import cnc_map, file_base, cnc_user, cnc_game


class MapPreview(file_base.CncNetFileBaseModel):
"""An image preview for a C&C Map upload.
.. note::
This class has no user link. The link to the user is via
:attr:`kirovy.models.map_preview.CncMapPreview.cnc_map`.
.. note::
Map previews are uploaded to the same directory as map files using
:func:`kirovy.models.cnc_map.CncMap.get_map_directory_path` through this class's
:attr:`kirovy.models.map_preview.CncMapPreview.cnc_map`.
"""

cnc_map_file = models.ForeignKey(cnc_map.CncMapFile, on_delete=models.CASCADE)
"""The map file that this preview belongs to. We link to the file so that we can have version-specific previews."""

ALLOWED_EXTENSION_TYPES = {
cnc_game.CncFileExtension.ExtensionTypes.IMAGE.value,
}

is_extracted = models.BooleanField(null=False, blank=False)
"""If true, then this image was extracted from the uploaded map file, usually generated by FinalAlert.
This will always be false for games released after Yuri's Revenge because Generals and beyond do not pack the
preview image into the map files.
"""

def save(self, *args, **kwargs):
self.name = self.cnc_map_file.name
self.cnc_game = self.cnc_map_file.cnc_game
return super().save(*args, **kwargs)

@staticmethod
def generate_upload_to(instance: "MapPreview", filename: str) -> pathlib.Path:
"""Generate the path to upload map previews to.
Gets called by :func:`kirovy.models.file_base._generate_upload_to` when ``CncMapFile.save`` is called.
See [the django docs for file fields](https://docs.djangoproject.com/en/5.0/ref/models/fields/#filefield).
``upload_to`` is set in :attr:`kirovy.models.file_base.CncNetFileBaseModel.file`, which calls
``_generate_upload_to``, which calls this function.
:param instance:
Acts as ``self``. The map preview object that we are creating an upload path for.
:param filename:
The filename of the uploaded file.
:return:
Path to upload map to relative to :attr:`~kirovy.settings.base.MEDIA_ROOT`.
"""
filename = pathlib.Path(filename)
filename_uuid = str(uuid.uuid4()).replace("-", "")
final_file_name = f"{instance.name}_{filename_uuid}{filename.suffix}"

# e.g. "yr/maps/CNC_NET_MAP_ID_HEX/ra2_map_file_name_UUID.png
return pathlib.Path(
instance.cnc_map_file.cnc_map.get_map_directory_path(), final_file_name
)
15 changes: 12 additions & 3 deletions kirovy/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,19 @@ def has_permission(self, request: KirovyRequest, view: View) -> bool:


class CanEdit(CanUpload):
"""
"""Check editing permissions.
Users can edit their own uploads.
Staff can edit user uploads.
Staff can edit all uploads.
Users that have been banned cannot edit anymore. Just in case they feel like defacing content in retaliation.
Checking if the **user** is banned happens in :func:`kirovy.permissions.CanUpload.has_permission` which runs
*before* ``has_object_permissions``. Checking if the **object** is banned is done via checking for an `
`is_banned`` attribute.
[DRF permission docs](https://www.django-rest-framework.org/api-guide/permissions/#custom-permissions).
The edit check flow for users: ``Is the user banned -> Is the object banned -> Does the user own the object``
"""

def has_object_permission(
Expand All @@ -62,7 +70,8 @@ def has_object_permission(

# Check if this model type is owned by users.
if isinstance(obj, cnc_user.CncNetUserOwnedModel):
return request.user == obj.cnc_user
obj_is_banned = hasattr(obj, "is_banned") and obj.is_banned
return request.user == obj.cnc_user and not obj_is_banned

return False

Expand Down
2 changes: 1 addition & 1 deletion kirovy/settings/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from kirovy.settings.base import *
from ._base import *
Loading

0 comments on commit 2ce9b6f

Please sign in to comment.