diff --git a/polylan_submitter/polylan_submitter/settings.py b/polylan_submitter/polylan_submitter/settings.py index 6d23b3a..8438260 100644 --- a/polylan_submitter/polylan_submitter/settings.py +++ b/polylan_submitter/polylan_submitter/settings.py @@ -16,6 +16,9 @@ import os # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +os.environ.setdefault("AWS_REQUEST_CHECKSUM_CALCULATION", "when_required") +os.environ.setdefault("AWS_RESPONSE_CHECKSUM_VALIDATION", "when_required") + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ @@ -45,10 +48,12 @@ INSTALLED_APPS = [ "noita", "games", "market", + "submissions", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -195,6 +200,16 @@ STATICFILES_DIRS = [ os.path.join(BASE_DIR, "static_source/vite"), ] +STORAGES = { + "default": { + "BACKEND": "storages.backends.s3.S3Storage", + }, + "staticfiles": { + "BACKEND": "storages.backends.s3.S3Storage", + }, +} + + from polylan_submitter.settingsLocal import * # noqa diff --git a/polylan_submitter/submissions/__init__.py b/polylan_submitter/submissions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/polylan_submitter/submissions/admin.py b/polylan_submitter/submissions/admin.py new file mode 100644 index 0000000..3d12bf1 --- /dev/null +++ b/polylan_submitter/submissions/admin.py @@ -0,0 +1,69 @@ +from django.contrib import admin +from .models import Submission, SubmissionFile + + +class SubmissionFileInline(admin.TabularInline): + model = SubmissionFile + extra = 0 + fields = [ + "original_filename", + "file_size", + "content_type", + "ocr_processed", + "ocr_error", + "created_at", + ] + readonly_fields = [ + "file_size", + "content_type", + "ocr_processed", + "ocr_error", + "created_at", + ] + + def get_queryset(self, request): + return super().get_queryset(request) + + +@admin.register(Submission) +class SubmissionAdmin(admin.ModelAdmin): + list_display = ["id_short", "user", "notes_preview", "created_at"] + list_filter = ["created_at", "updated_at"] + search_fields = ["id", "user__email", "notes"] + readonly_fields = ["id", "created_at", "updated_at"] + inlines = [SubmissionFileInline] + + def get_queryset(self, request): + return super().get_queryset(request).select_related("user") + + @admin.display(description="ID") + def id_short(self, obj): + return str(obj.id)[:8] + + @admin.display(description="Notes") + def notes_preview(self, obj): + if obj.notes: + return obj.notes[:50] + "..." if len(obj.notes) > 50 else obj.notes + return "-" + + +@admin.register(SubmissionFile) +class SubmissionFileAdmin(admin.ModelAdmin): + list_display = [ + "original_filename", + "submission_id", + "file_size", + "content_type", + "ocr_processed", + "created_at", + ] + list_filter = ["content_type", "ocr_processed", "created_at"] + search_fields = ["original_filename", "submission__id"] + readonly_fields = ["file_size", "content_type", "created_at", "updated_at"] + + def get_queryset(self, request): + return super().get_queryset(request).select_related("submission") + + @admin.display(description="Submission ID") + def submission_id(self, obj): + return str(obj.submission.id)[:8] diff --git a/polylan_submitter/submissions/apps.py b/polylan_submitter/submissions/apps.py new file mode 100644 index 0000000..1ffc7b6 --- /dev/null +++ b/polylan_submitter/submissions/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SubmissionsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "submissions" diff --git a/polylan_submitter/submissions/migrations/0001_initial.py b/polylan_submitter/submissions/migrations/0001_initial.py new file mode 100644 index 0000000..2db50d5 --- /dev/null +++ b/polylan_submitter/submissions/migrations/0001_initial.py @@ -0,0 +1,123 @@ +# Generated by Django 5.2.7 on 2026-05-24 21:34 + +import django.db.models.deletion +import submissions.models +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Submission", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "notes", + models.TextField( + blank=True, + help_text="Optional notes about the submission", + null=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + blank=True, + help_text="User who made the submission (null for anonymous)", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="submissions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="SubmissionFile", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "file", + models.FileField( + help_text="Uploaded file", + upload_to=submissions.models.submission_file_upload_path, + ), + ), + ( + "original_filename", + models.CharField( + help_text="Original filename as uploaded by user", + max_length=255, + ), + ), + ( + "file_size", + models.PositiveIntegerField(help_text="File size in bytes"), + ), + ( + "content_type", + models.CharField(help_text="MIME type of the file", max_length=100), + ), + ( + "ocr_processed", + models.BooleanField( + default=False, + help_text="Whether OCR has been processed for this file", + ), + ), + ( + "ocr_raw_data", + models.JSONField( + blank=True, help_text="Raw OCR data as JSON", null=True + ), + ), + ( + "ocr_error", + models.TextField( + blank=True, help_text="OCR processing error message" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "submission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="files", + to="submissions.submission", + ), + ), + ], + options={ + "verbose_name": "Submission File", + "verbose_name_plural": "Submission Files", + "ordering": ["file", "created_at"], + }, + ), + ] diff --git a/polylan_submitter/submissions/migrations/__init__.py b/polylan_submitter/submissions/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/polylan_submitter/submissions/models.py b/polylan_submitter/submissions/models.py new file mode 100644 index 0000000..cfcc449 --- /dev/null +++ b/polylan_submitter/submissions/models.py @@ -0,0 +1,91 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +import uuid + +from accounts.models import CustomUser + + +def submission_file_upload_path(instance, filename): + ext = filename.split(".")[-1] if "." in filename else "" + + new_filename = f"{instance.id}_{filename}" if ext else str(instance.id) + + return f"submissions/{instance.submission.id}/{new_filename}" + + +class Submission(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + user = models.ForeignKey( + CustomUser, + on_delete=models.CASCADE, + null=True, + blank=True, + help_text="User who made the submission (null for anonymous)", + related_name="submissions", + ) + + # Submission metadata + notes = models.TextField( + null=True, + blank=True, + help_text="Optional notes about the submission", + ) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + +class SubmissionFile(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + submission = models.ForeignKey( + Submission, + on_delete=models.CASCADE, + related_name="files", + ) + + file = models.FileField( + upload_to=submission_file_upload_path, + help_text=_("Uploaded file"), + ) + + original_filename = models.CharField( + max_length=255, help_text=_("Original filename as uploaded by user") + ) + file_size = models.PositiveIntegerField(help_text=_("File size in bytes")) + content_type = models.CharField( + max_length=100, + help_text=_("MIME type of the file"), + ) + + # OCR metadata + ocr_processed = models.BooleanField( + default=False, help_text="Whether OCR has been processed for this file" + ) + ocr_raw_data = models.JSONField( + null=True, blank=True, help_text="Raw OCR data as JSON" + ) + ocr_error = models.TextField(blank=True, help_text="OCR processing error message") + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["file", "created_at"] + verbose_name = "Submission File" + verbose_name_plural = "Submission Files" + + @property + def file_url(self) -> str | None: + if self.file: + return self.file.url + + def save(self, *args, **kwargs): + if self.file and not self.file_size: + self.file_size = self.file.size + + super().save(*args, **kwargs) diff --git a/polylan_submitter/submissions/tests.py b/polylan_submitter/submissions/tests.py new file mode 100644 index 0000000..a39b155 --- /dev/null +++ b/polylan_submitter/submissions/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/polylan_submitter/submissions/views.py b/polylan_submitter/submissions/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/polylan_submitter/submissions/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/pyproject.toml b/pyproject.toml index fb24227..ac83fd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,8 @@ dependencies = [ "sentry-sdk[django]>=2.59.0", "furl>=2.1.4", "django-redis>=6.0.0", + "django-storages[s3]>=1.14.6", + "whitenoise>=6.12.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 0881fdc..6220360 100644 --- a/uv.lock +++ b/uv.lock @@ -46,6 +46,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, ] +[[package]] +name = "boto3" +version = "1.43.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/4b/616367e871ce3f1cb3e8545a97736b6331b9fb081497f2d44c5b2aa6959d/boto3-1.43.14.tar.gz", hash = "sha256:5c0a994b3182061ee101812e721100717a4d664f9f4ceaf4a86b6d032ce9fc2d", size = 113142, upload-time = "2026-05-22T19:28:47.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/00/59cb9329c18e2d3aa23062ceaa87d065f2e81e7d2931df24d64e9a7815aa/boto3-1.43.14-py3-none-any.whl", hash = "sha256:574335744656cfed0b362a0a0467aaf2eb2bf15526edcd02d31d3c661f4b09e4", size = 140536, upload-time = "2026-05-22T19:28:46.49Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/3c/798d2f7deb118241930c7c6bcfb0b970d3f0245bf580700663199aeed2c3/botocore-1.43.14.tar.gz", hash = "sha256:b9e500737e43d2f147c9d4e23b54360335e77d4c0ba90a318f51b65e06cb8516", size = 15382604, upload-time = "2026-05-22T19:28:36.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/7e/6e64821077cd2efc4aa51b7d638fb6d48e1c7c450201c529fbaf1de8bfd3/botocore-1.43.14-py3-none-any.whl", hash = "sha256:1f4a2a95ea78c10398e78431e98c1fe47adb54a7b10a32975144c1f541186658", size = 15061424, upload-time = "2026-05-22T19:28:32.682Z" }, +] + [[package]] name = "certifi" version = "2025.10.5" @@ -232,6 +260,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/83/9d5da51836be6258f0af32008bceb0abf57ae73bf50a4e2c152fc511e6b2/django_shinobi-1.4.0-py3-none-any.whl", hash = "sha256:844044763d2bacf9d5a30cf00070896fcdd9a44f07be390bcc3051ef67ea98f9", size = 2429465, upload-time = "2025-08-26T21:20:51.112Z" }, ] +[[package]] +name = "django-storages" +version = "1.14.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" }, +] + +[package.optional-dependencies] +s3 = [ + { name = "boto3" }, +] + [[package]] name = "django-stubs" version = "5.2.7" @@ -433,6 +478,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "matplotlib-inline" version = "0.2.1" @@ -751,6 +805,7 @@ dependencies = [ { name = "django" }, { name = "django-redis" }, { name = "django-shinobi" }, + { name = "django-storages", extra = ["s3"] }, { name = "django-vite" }, { name = "furl" }, { name = "gunicorn" }, @@ -761,6 +816,7 @@ dependencies = [ { name = "pytesseract" }, { name = "requests" }, { name = "sentry-sdk", extra = ["django"] }, + { name = "whitenoise" }, ] [package.dev-dependencies] @@ -782,6 +838,7 @@ requires-dist = [ { name = "django", specifier = ">=5.2.7" }, { name = "django-redis", specifier = ">=6.0.0" }, { name = "django-shinobi", specifier = ">=1.4.0" }, + { name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" }, { name = "django-vite", specifier = ">=3.1.0" }, { name = "furl", specifier = ">=2.1.4" }, { name = "gunicorn", specifier = ">=23.0.0" }, @@ -792,6 +849,7 @@ requires-dist = [ { name = "pytesseract", specifier = ">=0.3.13" }, { name = "requests", specifier = ">=2.31.0" }, { name = "sentry-sdk", extras = ["django"], specifier = ">=2.59.0" }, + { name = "whitenoise", specifier = ">=6.12.0" }, ] [package.metadata.requires-dev] @@ -1150,6 +1208,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" }, ] +[[package]] +name = "s3transfer" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/ec/7c692cde9125b77e84b307354d4fb705f98b8ccad59a036d5957ca75bfc3/s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a", size = 155337, upload-time = "2026-04-29T22:07:36.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" }, +] + [[package]] name = "sentry-sdk" version = "2.59.0" @@ -1338,3 +1408,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc7 wheels = [ { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, ] + +[[package]] +name = "whitenoise" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" }, +]