Add signup form

This commit is contained in:
2026-05-12 21:10:54 +03:00
parent 6f19d10426
commit fe62575790
7 changed files with 116 additions and 2 deletions

View File

@@ -3,3 +3,9 @@ from django.apps import AppConfig
class SubmissionsConfig(AppConfig): class SubmissionsConfig(AppConfig):
name = "apps.submissions" name = "apps.submissions"
def ready(self) -> None:
# Imports the module for its `@receiver` side-effect (registers
# `unlink_stl_file_on_delete` with the `post_delete` signal).
# See `apps/submissions/signals.py` and plan.md §7.6.
from . import signals # noqa: F401

View File

View File

@@ -0,0 +1,74 @@
"""`python manage.py cleanup_stale` -- implements plan.md §7.6.
Permanently deletes submissions that have been stuck in `identifying`
(guest awaiting email confirmation) or `rejected` (auto- or operator-
rejected) for longer than the TTL window (default 24 h). Both the DB row
and any uploaded `.stl` file under `MEDIA_ROOT` are removed -- the
file-on-disk side is handled by the `post_delete` signal in
`apps/submissions/signals.py`.
Designed to be invoked on a loop from a sidecar service (plan.md §10):
while true; do python manage.py cleanup_stale; sleep 300; done
"""
from __future__ import annotations
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db.models import Q
from django.utils import timezone
from apps.submissions.models import Submission
class Command(BaseCommand):
help = (
"Delete submissions stuck in `identifying` or `rejected` for more "
"than --ttl-hours. Removes both the DB row and any uploaded STL."
)
def add_arguments(self, parser) -> None:
parser.add_argument(
"--ttl-hours",
type=int,
default=24,
help="How long a row can sit in `identifying` / `rejected` before "
"being deleted. Default: 24, matching plan.md §7.6.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Report what would be deleted but make no changes.",
)
def handle(self, *args, ttl_hours: int, dry_run: bool, **opts) -> None:
cutoff = timezone.now() - timedelta(hours=ttl_hours)
# `identifying` rows are aged off `created_at` (the row hasn't moved
# state since it was created). `rejected` rows are aged off
# `closed_at` (the moment the operator or auto-validator closed them).
stale = Submission.objects.filter(
Q(status=Submission.Status.IDENTIFYING, created_at__lt=cutoff)
| Q(status=Submission.Status.REJECTED, closed_at__lt=cutoff)
)
if dry_run:
count = stale.count()
self.stdout.write(
f"DRY RUN: would delete {count} stale submission(s) older than "
f"{ttl_hours} h."
)
for sub in stale:
self.stdout.write(f" - {sub.slug} ({sub.status})")
return
# `QuerySet.delete()` fires `post_delete` per row, which is how the
# uploaded `.stl` file gets unlinked from MEDIA_ROOT (see signals.py).
total, _by_model = stale.delete()
if total:
self.stdout.write(
self.style.SUCCESS(
f"Deleted {total} stale submission(s) older than {ttl_hours} h."
)
)

View File

@@ -0,0 +1,29 @@
"""Signal handlers for the submissions app.
The cleanup story (plan.md §7.6): when a `Submission` row is deleted --
manually, via `cleanup_stale`, or for any other reason -- the uploaded
`.stl` file must also be unlinked from `MEDIA_ROOT`. Django's `FileField`
does NOT do this automatically (the behaviour was removed in 1.3, ages
ago), so we wire a `post_delete` signal to take care of it.
"""
from __future__ import annotations
from django.db.models.signals import post_delete
from django.dispatch import receiver
from .models import Submission
@receiver(post_delete, sender=Submission)
def unlink_stl_file_on_delete(sender, instance: Submission, **kwargs) -> None:
"""Remove the on-disk `.stl` after a Submission row is deleted.
`FileField.delete(save=False)` is idempotent: it just calls
`Storage.delete(name)`, and `FileSystemStorage.delete` catches
`FileNotFoundError`. Safe to call on rows that never had a file
(e.g. URL-source submissions) -- the `if instance.stl_file` truthiness
check handles that.
"""
if instance.stl_file:
instance.stl_file.delete(save=False)

File diff suppressed because one or more lines are too long

View File

@@ -27,9 +27,14 @@
{% endif %} {% endif %}
<a href="/submit/" class="px-3 py-1.5 text-sm rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium">+ Submit a print</a> <a href="/submit/" class="px-3 py-1.5 text-sm rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium">+ Submit a print</a>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="ml-2 flex items-center gap-2 px-2 py-1 rounded-md hover:bg-slate-100"> <div class="ml-2 flex items-center gap-2 px-2 py-1">
<span class="w-7 h-7 rounded-full bg-gradient-to-br from-emerald-400 to-blue-500 grid place-items-center text-white text-xs font-bold">{{ user.username|slice:":1"|upper }}</span> <span class="w-7 h-7 rounded-full bg-gradient-to-br from-emerald-400 to-blue-500 grid place-items-center text-white text-xs font-bold">{{ user.username|slice:":1"|upper }}</span>
<span class="text-sm text-slate-700 hidden sm:inline">{{ user.username }}</span> <span class="text-sm text-slate-700 hidden sm:inline">{{ user.username }}</span>
<a href="{% url 'account_logout' %}" title="Sign out" aria-label="Sign out" class="ml-1 p-1 rounded-md text-slate-400 hover:text-slate-700 hover:bg-slate-100">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9"/>
</svg>
</a>
</div> </div>
{% else %} {% else %}
<a href="{% url 'account_login' %}" class="px-3 py-1.5 text-sm rounded-md text-slate-700 hover:bg-slate-100">Sign in</a> <a href="{% url 'account_login' %}" class="px-3 py-1.5 text-sm rounded-md text-slate-700 hover:bg-slate-100">Sign in</a>