From 0fdb8b8a02137fb465f0dd72045d3fc4db0915c6 Mon Sep 17 00:00:00 2001 From: Simonas Kareiva Date: Tue, 12 May 2026 19:35:15 +0300 Subject: [PATCH] App v0.1 --- apps/dashboard/urls.py | 15 ++ apps/dashboard/views.py | 88 +++++++- apps/submissions/admin.py | 92 ++++++++- apps/submissions/forms.py | 152 ++++++++++++++ apps/submissions/migrations/0001_initial.py | 193 ++++++++++++++++++ apps/submissions/models.py | 211 +++++++++++++++++++- apps/submissions/urls.py | 9 + apps/submissions/views.py | 98 ++++++++- assets/tailwind.source.css | 9 + hamprint/settings/base.py | 8 + hamprint/urls.py | 5 +- static/css/tailwind.css | 2 + templates/account/login.html | 45 +++++ templates/base.html | 53 +++++ templates/dashboard/index.html | 105 ++++++++++ templates/dashboard/my_prints.html | 85 ++++++++ templates/submissions/submit.html | 189 ++++++++++++++++++ 17 files changed, 1351 insertions(+), 8 deletions(-) create mode 100644 apps/dashboard/urls.py create mode 100644 apps/submissions/forms.py create mode 100644 apps/submissions/migrations/0001_initial.py create mode 100644 apps/submissions/urls.py create mode 100644 assets/tailwind.source.css create mode 100644 static/css/tailwind.css create mode 100644 templates/account/login.html create mode 100644 templates/base.html create mode 100644 templates/dashboard/index.html create mode 100644 templates/dashboard/my_prints.html create mode 100644 templates/submissions/submit.html diff --git a/apps/dashboard/urls.py b/apps/dashboard/urls.py new file mode 100644 index 0000000..1503879 --- /dev/null +++ b/apps/dashboard/urls.py @@ -0,0 +1,15 @@ +from django.urls import path + +from . import views + +app_name = "dashboard" + +urlpatterns = [ + path("", views.IndexView.as_view(), name="index"), + path("my-prints/", views.MyPrintsView.as_view(), name="my_prints"), + # Routes to be added as features land (see plan.md Section 7): + # path("p//", views.SubmissionDetailView.as_view(), name="detail"), + # path("p//status/", views.SubmissionStatusFragment.as_view(), name="status"), + # path("p//confirm//", views.ConfirmEmailView.as_view(), name="confirm"), + # path("p//resend/", views.ResendConfirmationView.as_view(), name="resend"), +] diff --git a/apps/dashboard/views.py b/apps/dashboard/views.py index 91ea44a..e939b10 100644 --- a/apps/dashboard/views.py +++ b/apps/dashboard/views.py @@ -1,3 +1,87 @@ -from django.shortcuts import render +from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Count, Q +from django.views.generic import ListView -# Create your views here. +from apps.submissions.models import Submission + + +class IndexView(ListView): + """Public dashboard (plan.md §8). + + Lists every submission whose status is one of the four dashboard-visible + states -- `verifying`, `queued`, `printing`, `completed`. Anything in + `identifying`, `processing`, `rejected`, or `failed` is excluded from + the listing (still reachable by direct slug URL by the submitter). + + Status-chip filtering via `?status=`; only the four dashboard- + visible values are honoured. Anything else falls back to the unfiltered + list, so the chips stay safe even if someone hand-edits the URL. + """ + + model = Submission + template_name = "dashboard/index.html" + context_object_name = "submissions" + paginate_by = 20 + + def _requested_status(self) -> str: + raw = self.request.GET.get("status", "") + allowed = {str(s) for s in Submission.DASHBOARD_VISIBLE_STATUSES} + return raw if raw in allowed else "" + + def get_queryset(self): + qs = Submission.objects.filter( + status__in=Submission.DASHBOARD_VISIBLE_STATUSES + ).select_related("requested_filament") + status = self._requested_status() + if status: + qs = qs.filter(status=status) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + # One conditional-aggregate for the chip counts, scoped to the same + # dashboard-visible filter so `total` matches what "All" would list. + ctx["counts"] = Submission.objects.filter( + status__in=Submission.DASHBOARD_VISIBLE_STATUSES + ).aggregate( + total=Count("id"), + verifying=Count("id", filter=Q(status=Submission.Status.VERIFYING)), + queued=Count("id", filter=Q(status=Submission.Status.QUEUED)), + printing=Count("id", filter=Q(status=Submission.Status.PRINTING)), + completed=Count("id", filter=Q(status=Submission.Status.COMPLETED)), + ) + ctx["active_status"] = self._requested_status() + return ctx + + +class MyPrintsView(LoginRequiredMixin, ListView): + """Private listing -- every submission the signed-in user has ever made. + + Anonymous users are bounced to allauth's `account_login` (the default + `LoginRequiredMixin.login_url`, configured via `LOGIN_URL`). Guests don't + have a `submitted_by`, so they have nothing to list here anyway. + """ + + model = Submission + template_name = "dashboard/my_prints.html" + context_object_name = "submissions" + paginate_by = 50 + + def get_queryset(self): + # Ordering inherited from `Submission.Meta` (-created_at). + return Submission.objects.filter( + submitted_by=self.request.user + ).select_related("requested_filament") + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + # Counts for the summary line in the page header. A single + # conditional-aggregate query beats N separate `.count()` calls. + agg = Submission.objects.filter(submitted_by=self.request.user).aggregate( + total=Count("id"), + queued=Count("id", filter=Q(status=Submission.Status.QUEUED)), + printing=Count("id", filter=Q(status=Submission.Status.PRINTING)), + completed=Count("id", filter=Q(status=Submission.Status.COMPLETED)), + ) + ctx["counts"] = agg + return ctx diff --git a/apps/submissions/admin.py b/apps/submissions/admin.py index 8c38f3f..2db0ad4 100644 --- a/apps/submissions/admin.py +++ b/apps/submissions/admin.py @@ -1,3 +1,93 @@ from django.contrib import admin +from django.utils.html import format_html -# Register your models here. +from .models import Filament, Submission + + +@admin.register(Filament) +class FilamentAdmin(admin.ModelAdmin): + list_display = ( + "display_label", + "material", + "color_name", + "swatch", + "is_available", + "sort_order", + "notes", + ) + list_editable = ("is_available", "sort_order", "notes") + list_filter = ("material", "is_available") + search_fields = ("color_name", "material", "notes") + readonly_fields = ("id", "created_at", "updated_at") + ordering = ("sort_order", "color_name") + + @admin.display(description="Swatch") + def swatch(self, obj: Filament) -> str: + if not obj.swatch_hex: + return "—" + return format_html( + '', + obj.swatch_hex, + ) + + +@admin.register(Submission) +class SubmissionAdmin(admin.ModelAdmin): + list_display = ( + "slug", + "status", + "submitter_display", + "source_type", + "requested_filament", + "created_at", + "closed_by", + ) + list_filter = ("status", "source_type", "requested_filament") + search_fields = ( + "slug", + "guest_email", + "submitted_by__username", + "submitted_by__email", + ) + date_hierarchy = "created_at" + ordering = ("-created_at",) + autocomplete_fields = ("requested_filament",) + readonly_fields = ( + "id", + "slug", + "confirmation_token", + "confirmation_sent_at", + "email_confirmed", + "created_at", + "updated_at", + "closed_at", + ) + fieldsets = ( + ("Identity", { + "fields": ("slug", "id", "submitted_by", "guest_email"), + }), + ("Source", { + "fields": ("source_type", "stl_file", "source_url", "requested_filament"), + }), + ("User-provided", { + "fields": ("notes_for_op",), + "description": "Private notes from the submitter -- never shown publicly.", + }), + ("Status & operator", { + "fields": ("status", "operator_notes", "closed_by", "closed_at"), + }), + ("Confirmation (guest path)", { + "fields": ("email_confirmed", "confirmation_token", "confirmation_sent_at"), + "classes": ("collapse",), + }), + ("Timestamps", { + "fields": ("created_at", "updated_at"), + "classes": ("collapse",), + }), + ) + + @admin.display(description="Submitter", ordering="submitted_by") + def submitter_display(self, obj: Submission) -> str: + if obj.submitted_by_id: + return str(obj.submitted_by) + return f"guest <{obj.guest_email}>" if obj.guest_email else "guest" diff --git a/apps/submissions/forms.py b/apps/submissions/forms.py new file mode 100644 index 0000000..7667334 --- /dev/null +++ b/apps/submissions/forms.py @@ -0,0 +1,152 @@ +"""SubmissionForm -- the public submit form (plan.md §5 + demo/submit.html). + +Mixes crispy-forms (FormHelper for layout consistency) with manually rendered +radio cards in the template, because the four source-type tabs need +`peer-checked/:` Tailwind classes that crispy can't emit by itself. +""" + +from __future__ import annotations + +from urllib.parse import urlparse + +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Field, Layout, Submit +from django import forms + +from .models import Filament, Submission + +# Per plan.md §7.5.3 + the form's host allow-list. Subdomains (www. and bare) +# are both accepted. +URL_HOST_ALLOW_LIST: dict[str, set[str]] = { + Submission.SourceType.PRINTABLES: {"printables.com", "www.printables.com"}, + Submission.SourceType.MAKERWORLD: {"makerworld.com", "www.makerworld.com"}, + Submission.SourceType.THINGIVERSE: {"thingiverse.com", "www.thingiverse.com"}, +} + +# Shared Tailwind class string for text-ish inputs, matched against demo/submit.html. +INPUT_CSS = ( + "w-full px-3 py-2 rounded-md border border-slate-300 bg-white " + "focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500" +) + + +class SubmissionForm(forms.ModelForm): + """Single-page submit form. + + The view passes `user=request.user` so the form can decide whether + `guest_email` is required (anonymous) or hidden (Google-authenticated). + """ + + source_type = forms.ChoiceField( + choices=Submission.SourceType.choices, + initial=Submission.SourceType.UPLOAD, + widget=forms.RadioSelect, + ) + stl_file = forms.FileField( + required=False, + widget=forms.ClearableFileInput(attrs={"accept": ".stl", "class": "sr-only"}), + ) + source_url = forms.URLField( + required=False, + widget=forms.URLInput( + attrs={"placeholder": "https://...", "class": INPUT_CSS} + ), + ) + + class Meta: + model = Submission + fields = ( + "source_type", + "stl_file", + "source_url", + "requested_filament", + "notes_for_op", + "guest_email", + ) + widgets = { + "notes_for_op": forms.Textarea( + attrs={ + "rows": 3, + "class": INPUT_CSS, + "placeholder": ( + "e.g. 0.2 mm layer height, 20% infill, pickup Saturday afternoon." + ), + } + ), + "guest_email": forms.EmailInput( + attrs={"class": INPUT_CSS, "placeholder": "you@example.com"} + ), + "requested_filament": forms.Select(attrs={"class": INPUT_CSS}), + } + + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, **kwargs) + + # Filament dropdown shows only operator-curated, currently-loaded rows. + self.fields["requested_filament"].queryset = Filament.objects.filter( + is_available=True + ) + self.fields["requested_filament"].label = "Filament" + self.fields["requested_filament"].empty_label = ( + "No preference — operator's choice" + ) + self.fields["requested_filament"].required = False + self.fields["notes_for_op"].required = False + + if user is not None and user.is_authenticated: + # OAuth user: email is already verified through Google, no need to + # ask again. Drop the field so it doesn't render or accept input. + self.fields.pop("guest_email") + else: + self.fields["guest_email"].required = True + self.fields["guest_email"].label = "Your email" + + # Crispy helper -- the template still owns the
tag (CSRF + + # enctype + custom radio-card markup live there), so we keep form_tag + # off and lean on crispy only for individual {{ field|as_crispy_field }} + # renders of the boring fields. + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.disable_csrf = True + layout_fields = ["source_type", "stl_file", "source_url", "requested_filament", "notes_for_op"] + if "guest_email" in self.fields: + layout_fields.append("guest_email") + self.helper.layout = Layout(*[Field(f) for f in layout_fields]) + self.helper.add_input(Submit("submit", "Submit print")) + + # --- validation --------------------------------------------------------- + + def clean(self): + cleaned = super().clean() + source_type = cleaned.get("source_type") + stl_file = cleaned.get("stl_file") + source_url = cleaned.get("source_url") + + if source_type == Submission.SourceType.UPLOAD: + if not stl_file: + self.add_error( + "stl_file", + "Pick an .stl file to upload (max 4 MB) or switch to a URL source.", + ) + # The user might have left text in source_url from a prior selection; + # silently drop it so the saved row stays consistent. + cleaned["source_url"] = "" + else: + if not source_url: + self.add_error( + "source_url", + "Paste the model URL or switch to a raw .stl upload.", + ) + else: + allowed = URL_HOST_ALLOW_LIST.get(source_type, set()) + host = urlparse(source_url).hostname or "" + if host not in allowed: + pretty_hosts = ", ".join(sorted(allowed)) + self.add_error( + "source_url", + f"URL host must be one of: {pretty_hosts}. Got '{host}'.", + ) + # Conversely, drop any uploaded file that doesn't apply. + cleaned["stl_file"] = None + + return cleaned diff --git a/apps/submissions/migrations/0001_initial.py b/apps/submissions/migrations/0001_initial.py new file mode 100644 index 0000000..c57d7f5 --- /dev/null +++ b/apps/submissions/migrations/0001_initial.py @@ -0,0 +1,193 @@ +# Generated by Django 6.0.5 on 2026-05-12 15:32 + +import apps.submissions.models +import django.core.validators +import django.db.models.deletion +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="Filament", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "material", + models.CharField( + choices=[ + ("PLA", "PLA"), + ("PLA+", "PLA+"), + ("PETG", "PETG"), + ("ABS", "ABS"), + ("TPU", "TPU"), + ("ASA", "ASA"), + ("Nylon", "Nylon"), + ("Other", "Other"), + ], + max_length=16, + ), + ), + ("color_name", models.CharField(max_length=64)), + ( + "swatch_hex", + models.CharField(blank=True, help_text="#RRGGBB", max_length=7), + ), + ("is_available", models.BooleanField(default=True)), + ("notes", models.CharField(blank=True, max_length=200)), + ("sort_order", models.IntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "ordering": ("sort_order", "color_name"), + "indexes": [ + models.Index( + fields=["is_available"], name="submissions_is_avai_6de7b0_idx" + ), + models.Index( + fields=["sort_order", "color_name"], + name="submissions_sort_or_59555c_idx", + ), + ], + }, + ), + migrations.CreateModel( + name="Submission", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("slug", models.CharField(db_index=True, max_length=64, unique=True)), + ( + "guest_email", + models.EmailField(blank=True, max_length=254, null=True), + ), + ("email_confirmed", models.BooleanField(default=False)), + ( + "confirmation_token", + models.CharField( + blank=True, db_index=True, max_length=64, null=True + ), + ), + ("confirmation_sent_at", models.DateTimeField(blank=True, null=True)), + ( + "source_type", + models.CharField( + choices=[ + ("upload", "Raw .stl upload"), + ("printables", "Printables.com"), + ("makerworld", "MakerWorld"), + ("thingiverse", "Thingiverse"), + ], + default="upload", + max_length=16, + ), + ), + ( + "stl_file", + models.FileField( + blank=True, + null=True, + upload_to="stl/", + validators=[ + django.core.validators.FileExtensionValidator( + allowed_extensions=["stl"] + ), + apps.submissions.models._validate_stl_size, + ], + ), + ), + ("source_url", models.URLField(blank=True, null=True)), + ("notes_for_op", models.TextField(blank=True)), + ( + "status", + models.CharField( + choices=[ + ("identifying", "Identifying"), + ("processing", "Processing"), + ("verifying", "Verifying"), + ("queued", "Queued"), + ("printing", "Printing"), + ("completed", "Completed"), + ("rejected", "Rejected"), + ("failed", "Failed"), + ], + db_index=True, + default="identifying", + max_length=16, + ), + ), + ("operator_notes", models.TextField(blank=True)), + ("closed_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "closed_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="closed_submissions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "requested_filament", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="submissions", + to="submissions.filament", + ), + ), + ( + "submitted_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="submissions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ("-created_at",), + "constraints": [ + models.CheckConstraint( + condition=models.Q( + ("submitted_by__isnull", False), + ("guest_email__isnull", False), + _connector="OR", + ), + name="submission_has_contact_identity", + ) + ], + }, + ), + ] diff --git a/apps/submissions/models.py b/apps/submissions/models.py index 71a8362..c51cdb3 100644 --- a/apps/submissions/models.py +++ b/apps/submissions/models.py @@ -1,3 +1,210 @@ -from django.db import models +"""Submission + Filament models -- the data layer for plan.md §5. -# Create your models here. +The state machine lives in `Submission.Status`; transitions and side-effects +are documented in plan.md §7. +""" + +from __future__ import annotations + +import uuid + +from django.conf import settings +from django.core.validators import FileExtensionValidator +from django.db import models +from django.urls import reverse + + +def _validate_stl_size(uploaded_file) -> None: + """Hard 4 MB cap on uploaded `.stl` files -- mirrors plan.md §5.""" + from django.core.exceptions import ValidationError + + max_bytes = 4 * 1024 * 1024 + if uploaded_file.size > max_bytes: + raise ValidationError( + f"STL file is {uploaded_file.size // 1024} KB; max is " + f"{max_bytes // 1024} KB." + ) + + +class Filament(models.Model): + """Operator-curated filament inventory (plan.md §5). + + The submit form's "Filament" dropdown is populated from rows with + `is_available = True`. Filaments are protected from deletion while + referenced by an in-flight `Submission`; the correct workflow is to + flip `is_available = False`. + """ + + class Material(models.TextChoices): + PLA = "PLA", "PLA" + PLA_PLUS = "PLA+", "PLA+" + PETG = "PETG", "PETG" + ABS = "ABS", "ABS" + TPU = "TPU", "TPU" + ASA = "ASA", "ASA" + NYLON = "Nylon", "Nylon" + OTHER = "Other", "Other" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + material = models.CharField(max_length=16, choices=Material.choices) + color_name = models.CharField(max_length=64) + swatch_hex = models.CharField(max_length=7, blank=True, help_text="#RRGGBB") + is_available = models.BooleanField(default=True) + notes = models.CharField(max_length=200, blank=True) + sort_order = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("sort_order", "color_name") + indexes = [ + models.Index(fields=["is_available"]), + models.Index(fields=["sort_order", "color_name"]), + ] + + def __str__(self) -> str: + return self.display_label + + @property + def display_label(self) -> str: + return f"{self.color_name} {self.material}" + + +class Submission(models.Model): + """A 3D-print job. State machine documented in plan.md §7.""" + + class Status(models.TextChoices): + IDENTIFYING = "identifying", "Identifying" + PROCESSING = "processing", "Processing" + VERIFYING = "verifying", "Verifying" + QUEUED = "queued", "Queued" + PRINTING = "printing", "Printing" + COMPLETED = "completed", "Completed" + REJECTED = "rejected", "Rejected" + FAILED = "failed", "Failed" + + class SourceType(models.TextChoices): + UPLOAD = "upload", "Raw .stl upload" + PRINTABLES = "printables", "Printables.com" + MAKERWORLD = "makerworld", "MakerWorld" + THINGIVERSE = "thingiverse", "Thingiverse" + + DASHBOARD_VISIBLE_STATUSES = ( + Status.VERIFYING, + Status.QUEUED, + Status.PRINTING, + Status.COMPLETED, + ) + TERMINAL_STATUSES = (Status.COMPLETED, Status.FAILED, Status.REJECTED) + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + slug = models.CharField(max_length=64, unique=True, db_index=True) + + submitted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="submissions", + ) + guest_email = models.EmailField(null=True, blank=True) + email_confirmed = models.BooleanField(default=False) + confirmation_token = models.CharField( + max_length=64, null=True, blank=True, db_index=True + ) + confirmation_sent_at = models.DateTimeField(null=True, blank=True) + + source_type = models.CharField( + max_length=16, + choices=SourceType.choices, + default=SourceType.UPLOAD, + ) + stl_file = models.FileField( + upload_to="stl/", + null=True, + blank=True, + validators=[ + FileExtensionValidator(allowed_extensions=["stl"]), + _validate_stl_size, + ], + ) + source_url = models.URLField(null=True, blank=True) + requested_filament = models.ForeignKey( + Filament, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="submissions", + ) + + notes_for_op = models.TextField(blank=True) + + status = models.CharField( + max_length=16, + choices=Status.choices, + default=Status.IDENTIFYING, + db_index=True, + ) + operator_notes = models.TextField(blank=True) + closed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="closed_submissions", + ) + closed_at = models.DateTimeField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("-created_at",) + constraints = [ + models.CheckConstraint( + name="submission_has_contact_identity", + condition=( + models.Q(submitted_by__isnull=False) + | models.Q(guest_email__isnull=False) + ), + ), + ] + + def __str__(self) -> str: + return f"{self.slug} ({self.get_status_display()})" + + def get_absolute_url(self) -> str: + # The dashboard detail route is not wired yet; fall back to the + # dashboard index so post-submit redirects always land somewhere real. + return reverse("dashboard:index") + + # --- presentation helpers (consumed by dashboard templates) ------------- + + SOURCE_LABEL = { + SourceType.UPLOAD: ".stl upload", + SourceType.PRINTABLES: "printables.com", + SourceType.MAKERWORLD: "makerworld.com", + SourceType.THINGIVERSE: "thingiverse.com", + } + + # Tailwind class string per status, kept here (not in the template) so the + # palette is defined in one place and Tailwind's @source-scanner picks the + # literal class strings up from apps/. + STATUS_BADGE_CLASS = { + Status.IDENTIFYING: "bg-yellow-100 text-yellow-800", + Status.PROCESSING: "bg-slate-100 text-slate-700", + Status.VERIFYING: "bg-violet-100 text-violet-800", + Status.QUEUED: "bg-blue-100 text-blue-800", + Status.PRINTING: "bg-orange-100 text-orange-800", + Status.COMPLETED: "bg-emerald-100 text-emerald-800", + Status.REJECTED: "bg-red-100 text-red-800", + Status.FAILED: "bg-red-100 text-red-800", + } + + @property + def source_label(self) -> str: + return self.SOURCE_LABEL.get(self.source_type, self.source_type) + + @property + def status_badge_class(self) -> str: + return self.STATUS_BADGE_CLASS.get(self.status, "bg-slate-100 text-slate-700") diff --git a/apps/submissions/urls.py b/apps/submissions/urls.py new file mode 100644 index 0000000..5283851 --- /dev/null +++ b/apps/submissions/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "submissions" + +urlpatterns = [ + path("", views.SubmitView.as_view(), name="create"), +] diff --git a/apps/submissions/views.py b/apps/submissions/views.py index 91ea44a..3fd68c1 100644 --- a/apps/submissions/views.py +++ b/apps/submissions/views.py @@ -1,3 +1,97 @@ -from django.shortcuts import render +"""Submit form view (plan.md §7.4 + §7). -# Create your views here. +The view persists a `Submission` and, depending on whether the request is +authenticated, sets the initial state machine state per plan.md §7.3: + + OAuth user -> processing (email already verified) + guest with email -> identifying (waiting for confirmation link click) + +Slug generation, email sending, and validation cron are out of scope here and +deferred to plan.md §7.5 (`processor` sidecar) and §7.5 confirmation flow. +""" + +from __future__ import annotations + +import secrets + +import namesgenerator +from django.contrib import messages +from django.db import IntegrityError, transaction +from django.urls import reverse_lazy +from django.utils import timezone +from django.views.generic import CreateView + +from .forms import SubmissionForm +from .models import Submission + + +def _generate_unique_slug(max_attempts: int = 16) -> str: + """`namesgenerator.get_random_name` with collision retries. + + The slug column is `unique=True`; we retry instead of looping in SQL so + the rare collision path stays observable. + """ + for _ in range(max_attempts): + candidate = namesgenerator.get_random_name() + if not Submission.objects.filter(slug=candidate).exists(): + return candidate + # Fall through: extremely unlikely, but caller will see IntegrityError + # if the next save() still collides. + return namesgenerator.get_random_name() + + +class SubmitView(CreateView): + """Public submit form. GET renders, POST creates a Submission.""" + + form_class = SubmissionForm + template_name = "submissions/submit.html" + success_url = reverse_lazy("dashboard:index") + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + return kwargs + + @transaction.atomic + def form_valid(self, form): + submission: Submission = form.save(commit=False) + submission.slug = _generate_unique_slug() + + if self.request.user.is_authenticated: + submission.submitted_by = self.request.user + submission.guest_email = None + submission.email_confirmed = True + submission.status = Submission.Status.PROCESSING + else: + submission.submitted_by = None + # guest_email is already on the form's cleaned_data, ModelForm + # populated it onto the instance. + submission.email_confirmed = False + submission.status = Submission.Status.IDENTIFYING + submission.confirmation_token = secrets.token_urlsafe(32) + submission.confirmation_sent_at = timezone.now() + # TODO(plan.md §7.4 step 6): send the confirmation email here. + + try: + submission.save() + except IntegrityError: + # Extremely rare slug collision on the unique index; one more try. + submission.slug = _generate_unique_slug() + submission.save() + + if submission.status == Submission.Status.IDENTIFYING: + messages.info( + self.request, + f"Submission {submission.slug} created. Check your email " + f"({submission.guest_email}) for a confirmation link -- you " + f"have 24 hours.", + ) + else: + messages.success( + self.request, + f"Submission {submission.slug} accepted. We'll start " + f"validating it shortly.", + ) + + self.object = submission + return super().form_valid(form) diff --git a/assets/tailwind.source.css b/assets/tailwind.source.css new file mode 100644 index 0000000..60795e8 --- /dev/null +++ b/assets/tailwind.source.css @@ -0,0 +1,9 @@ +@import "tailwindcss"; + +/* Tailwind 4 auto-detects most file types but does not scan Python files, + * and its default walk excludes folders outside the CWD it was started in. + * Be explicit about the two locations where we put Tailwind utility classes: + * Django templates and Python widget-attrs. + */ +@source "../templates"; +@source "../apps"; diff --git a/hamprint/settings/base.py b/hamprint/settings/base.py index 3e0425d..7d47687 100644 --- a/hamprint/settings/base.py +++ b/hamprint/settings/base.py @@ -30,6 +30,7 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.sites", + "django.contrib.humanize", # Third-party "allauth", "allauth.account", @@ -38,6 +39,7 @@ INSTALLED_APPS = [ "anymail", "crispy_forms", "crispy_tailwind", + "django_tailwind_cli", # Local "apps.submissions", "apps.dashboard", @@ -131,6 +133,12 @@ DEFAULT_FROM_EMAIL = env( CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind" CRISPY_TEMPLATE_PACK = "tailwind" +# Tailwind CSS --- keep source.css in the repo (and outside the +# auto-managed .django_tailwind_cli/ directory) so we can add explicit +# `@source` directives for the template + app directories without the +# next `tailwind build` reverting them. +TAILWIND_CLI_SRC_CSS = BASE_DIR / "assets" / "tailwind.source.css" + # Allauth. We run our own confirmation flow for guests, so allauth's own email # verification stays off for Google-authenticated users (they're already verified). ACCOUNT_EMAIL_VERIFICATION = "none" diff --git a/hamprint/urls.py b/hamprint/urls.py index 63bbd03..3d3cc37 100644 --- a/hamprint/urls.py +++ b/hamprint/urls.py @@ -16,8 +16,11 @@ Including another URLconf """ from django.contrib import admin -from django.urls import path +from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), + path("accounts/", include("allauth.urls")), + path("", include("apps.dashboard.urls")), + path("submit/", include("apps.submissions.urls")), ] diff --git a/static/css/tailwind.css b/static/css/tailwind.css new file mode 100644 index 0000000..20505da --- /dev/null +++ b/static/css/tailwind.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-emerald-900:oklch(37.8% .077 168.94);--color-cyan-100:oklch(95.6% .045 203.388);--color-cyan-700:oklch(52% .105 223.128);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-800:oklch(43.2% .232 292.759);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-xl:36rem;--container-3xl:48rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.relative{position:relative}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-16{height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-64{width:calc(var(--spacing) * 64)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:calc(var(--spacing) * 0)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.animate-pulse{animation:var(--animate-pulse)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-orange-200{border-color:var(--color-orange-200)}.border-red-200{border-color:var(--color-red-200)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-slate-900{border-color:var(--color-slate-900)}.border-yellow-200{border-color:var(--color-yellow-200)}.border-yellow-300{border-color:var(--color-yellow-300)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-cyan-100{background-color:var(--color-cyan-100)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-300{background-color:var(--color-slate-300)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-white{background-color:var(--color-white)}.bg-yellow-50{background-color:var(--color-yellow-50)}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-yellow-600{background-color:var(--color-yellow-600)}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-emerald-400{--tw-gradient-from:var(--color-emerald-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-orange-50{--tw-gradient-from:var(--color-orange-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-amber-50{--tw-gradient-to:var(--color-amber-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-blue-500{--tw-gradient-to:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-6{padding-top:calc(var(--spacing) * 6)}.text-center{text-align:center}.text-left{text-align:left}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.whitespace-pre-line{white-space:pre-line}.text-amber-300{color:var(--color-amber-300)}.text-amber-400{color:var(--color-amber-400)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800\/90{color:#953d00e6}@supports (color:color-mix(in lab, red, red)){.text-amber-800\/90{color:color-mix(in oklab, var(--color-amber-800) 90%, transparent)}}.text-amber-900{color:var(--color-amber-900)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-900{color:var(--color-blue-900)}.text-blue-900\/80{color:#1c398ecc}@supports (color:color-mix(in lab, red, red)){.text-blue-900\/80{color:color-mix(in oklab, var(--color-blue-900) 80%, transparent)}}.text-cyan-700{color:var(--color-cyan-700)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-emerald-900{color:var(--color-emerald-900)}.text-emerald-900\/80{color:#004e3bcc}@supports (color:color-mix(in lab, red, red)){.text-emerald-900\/80{color:color-mix(in oklab, var(--color-emerald-900) 80%, transparent)}}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-orange-800{color:var(--color-orange-800)}.text-orange-900{color:var(--color-orange-900)}.text-orange-900\/80{color:#7e2a0ccc}@supports (color:color-mix(in lab, red, red)){.text-orange-900\/80{color:color-mix(in oklab, var(--color-orange-900) 80%, transparent)}}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-800{color:var(--color-red-800)}.text-red-900{color:var(--color-red-900)}.text-red-900\/80{color:#82181acc}@supports (color:color-mix(in lab, red, red)){.text-red-900\/80{color:color-mix(in oklab, var(--color-red-900) 80%, transparent)}}.text-slate-200{color:var(--color-slate-200)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-violet-800{color:var(--color-violet-800)}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-800{color:var(--color-yellow-800)}.text-yellow-900{color:var(--color-yellow-900)}.text-yellow-900\/70{color:#733e0ab3}@supports (color:color-mix(in lab, red, red)){.text-yellow-900\/70{color:color-mix(in oklab, var(--color-yellow-900) 70%, transparent)}}.text-yellow-900\/80{color:#733e0acc}@supports (color:color-mix(in lab, red, red)){.text-yellow-900\/80{color:color-mix(in oklab, var(--color-yellow-900) 80%, transparent)}}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.opacity-50{opacity:.5}.opacity-70{opacity:.7}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-4{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-blue-100{--tw-ring-color:var(--color-blue-100)}.ring-emerald-100{--tw-ring-color:var(--color-emerald-100)}.ring-orange-100{--tw-ring-color:var(--color-orange-100)}.ring-red-100{--tw-ring-color:var(--color-red-100)}.ring-yellow-100{--tw-ring-color:var(--color-yellow-100)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.peer-checked\/makerworld\:border-amber-500:is(:where(.peer\/makerworld):checked~*){border-color:var(--color-amber-500)}.peer-checked\/makerworld\:bg-amber-50:is(:where(.peer\/makerworld):checked~*){background-color:var(--color-amber-50)}.peer-checked\/makerworld\:font-medium:is(:where(.peer\/makerworld):checked~*){--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.peer-checked\/makerworld\:text-amber-900:is(:where(.peer\/makerworld):checked~*){color:var(--color-amber-900)}.peer-checked\/printables\:border-amber-500:is(:where(.peer\/printables):checked~*){border-color:var(--color-amber-500)}.peer-checked\/printables\:bg-amber-50:is(:where(.peer\/printables):checked~*){background-color:var(--color-amber-50)}.peer-checked\/printables\:font-medium:is(:where(.peer\/printables):checked~*){--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.peer-checked\/printables\:text-amber-900:is(:where(.peer\/printables):checked~*){color:var(--color-amber-900)}.peer-checked\/thingiverse\:border-amber-500:is(:where(.peer\/thingiverse):checked~*){border-color:var(--color-amber-500)}.peer-checked\/thingiverse\:bg-amber-50:is(:where(.peer\/thingiverse):checked~*){background-color:var(--color-amber-50)}.peer-checked\/thingiverse\:font-medium:is(:where(.peer\/thingiverse):checked~*){--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.peer-checked\/thingiverse\:text-amber-900:is(:where(.peer\/thingiverse):checked~*){color:var(--color-amber-900)}.peer-checked\/upload\:border-amber-500:is(:where(.peer\/upload):checked~*){border-color:var(--color-amber-500)}.peer-checked\/upload\:bg-amber-50:is(:where(.peer\/upload):checked~*){background-color:var(--color-amber-50)}.peer-checked\/upload\:font-medium:is(:where(.peer\/upload):checked~*){--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.peer-checked\/upload\:text-amber-900:is(:where(.peer\/upload):checked~*){color:var(--color-amber-900)}@media (hover:hover){.hover\:border-amber-400:hover{border-color:var(--color-amber-400)}.hover\:border-amber-500:hover{border-color:var(--color-amber-500)}.hover\:bg-amber-50\/40:hover{background-color:#fffbeb66}@supports (color:color-mix(in lab, red, red)){.hover\:bg-amber-50\/40:hover{background-color:color-mix(in oklab, var(--color-amber-50) 40%, transparent)}}.hover\:bg-amber-600:hover{background-color:var(--color-amber-600)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-yellow-700:hover{background-color:var(--color-yellow-700)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-amber-500:focus{border-color:var(--color-amber-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-amber-500:focus{--tw-ring-color:var(--color-amber-500)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}@media (min-width:40rem){.sm\:inline{display:inline}.sm\:inline-flex{display:inline-flex}.sm\:table-cell{display:table-cell}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/templates/account/login.html b/templates/account/login.html new file mode 100644 index 0000000..bac47d9 --- /dev/null +++ b/templates/account/login.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block title %}Sign in — hamprint{% endblock %} + +{% block content %} +
+
+
+
h
+

Sign in to hamprint

+

+ Optional — you can also + submit as a guest. +

+
+ + {% comment %} + The button is a GET to allauth's google_login view -- not a POST form -- + because the actual OAuth handshake happens via a 302 redirect to Google. + The `?next=` from `/accounts/login/?next=/wherever/` is forwarded so the + user lands back on the page they were trying to reach. + {% endcomment %} + + + Continue with Google + + + + +
+

Signed-in prints skip email confirmation.

+

See all your past prints in one place.

+

We don't share your email, ever.

+
+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..d2de150 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,53 @@ +{% load tailwind_cli %} + + + + + {% block title %}hamprint — public 3D print dashboard{% endblock %} + {% tailwind_css %} + + {% block extra_head %}{% endblock %} + + + + + +
+ {% block content %}{% endblock %} +
+ +
+
+

A community service of hamlab.lt

+ {% if user.is_staff %}

Operators: admin panel.

{% endif %} +
+
+ + + diff --git a/templates/dashboard/index.html b/templates/dashboard/index.html new file mode 100644 index 0000000..c87b9bb --- /dev/null +++ b/templates/dashboard/index.html @@ -0,0 +1,105 @@ +{% extends "base.html" %} +{% load humanize %} + +{% block title %}Print dashboard — hamprint{% endblock %} + +{% block content %} +
+

Print dashboard

+

Every public print job submitted to the hamlab.lt 3d printers. Look up your submission by its codename. Prints are pickup-only.

+
+ +{% comment %} + Status filter chips. The "All" chip is active when no ?status= filter is + set. Each specific chip is active when its value matches `active_status`. +{% endcomment %} + + +{% if submissions %} +
+ + + + + + + + + + {% for sub in submissions %} + + + + + + {% endfor %} + +
CodenameStatus
+ {{ sub.slug }} + {% if user.is_authenticated and sub.submitted_by_id == user.id %} + yours + {% endif %} + + {{ sub.get_status_display }} +
+
+ + {% if is_paginated %} +
+

+ Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} + · {{ page_obj.paginator.count }} total +

+
+ {% if page_obj.has_previous %} + ← Previous + {% else %} + ← Previous + {% endif %} + {% if page_obj.has_next %} + Next → + {% else %} + Next → + {% endif %} +
+
+ {% endif %} +{% else %} +
+

+ {% if active_status %}No submissions in {{ active_status }} right now.{% else %}No submissions yet.{% endif %} +

+

+ {% if active_status %}Try one of the other filter chips above, or{% else %}Be the first —{% endif %} + submit a print and you'll see it appear here once an operator has verified it. +

+ + Submit a print +
+{% endif %} + +{% if not user.is_authenticated %} +
+
?
+
+

Don't have an account?

+

You don't need one. Just hit Submit a print, give us an email, and we'll send you a codename to track your job.

+
+
+{% endif %} +{% endblock %} diff --git a/templates/dashboard/my_prints.html b/templates/dashboard/my_prints.html new file mode 100644 index 0000000..4416400 --- /dev/null +++ b/templates/dashboard/my_prints.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% load humanize %} + +{% block title %}My prints — hamprint{% endblock %} + +{% block content %} +
+
+

My prints

+

+ All prints submitted with + {{ user.email|default:user.get_username }}. +

+
+
+ {{ counts.total }} total + {% if counts.queued %}· {{ counts.queued }} queued{% endif %} + {% if counts.printing %}· {{ counts.printing }} printing{% endif %} + {% if counts.completed %}· {{ counts.completed }} completed{% endif %} +
+
+ +{% if submissions %} +
+ + + + + + + + + + + {% for sub in submissions %} + + + + + + + {% endfor %} + +
CodenameSourceStatus
+ {{ sub.slug }} + {{ sub.source_label }} + + {{ sub.get_status_display }} + +
+
+ + {% if is_paginated %} +
+

+ Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} + · {{ page_obj.paginator.count }} total +

+
+ {% if page_obj.has_previous %} + ← Previous + {% else %} + ← Previous + {% endif %} + {% if page_obj.has_next %} + Next → + {% else %} + Next → + {% endif %} +
+
+ {% endif %} +{% else %} +
+

No prints yet.

+

Once you submit one, it'll show up here.

+ + Submit a print +
+{% endif %} + + +{% endblock %} diff --git a/templates/submissions/submit.html b/templates/submissions/submit.html new file mode 100644 index 0000000..da867e7 --- /dev/null +++ b/templates/submissions/submit.html @@ -0,0 +1,189 @@ +{% extends "base.html" %} + +{% block title %}Submit a print — hamprint{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+
+

Submit a print

+

Anyone can submit a job. We'll send you a codename to follow it on the dashboard.

+
+ + {% if user.is_authenticated %} +
+ +

Signed in as {{ user.email|default:user.get_username }}. No email confirmation needed.

+
+ {% else %} +
+ Guest +

You're submitting anonymously. We'll email you a confirmation link — click it within 24 hours to put your print in the queue. Sign in with Google to skip this step.

+
+ {% endif %} + + + {% csrf_token %} + + {% if form.non_field_errors %} +
+ {{ form.non_field_errors|join:" " }} +
+ {% endif %} + + {% comment %} + Source-type radio cards. Hardcoded (not looped) so Tailwind's scanner + sees the literal `peer/` and `peer-checked/:` class strings + and emits the matching CSS rules. + {% endcomment %} +
+ Where is the model coming from? * + +
+ + + + + + + + + + + +
+ + {% comment %} + Upload pane. The native file input stays sr-only and is triggered by + the wrapping
+ + {# Filament #} +
+ + {{ form.requested_filament }} +

Only filaments currently loaded at hamlab.lt are listed. The list is curated by operators — out-of-stock options are hidden.

+ {% if form.requested_filament.errors %}

{{ form.requested_filament.errors|join:" " }}

{% endif %} +
+ + {# Notes (private) #} +
+ + {{ form.notes_for_op }} +

Only the operator sees these — they're not shown on your public submission page.

+ {% if form.notes_for_op.errors %}

{{ form.notes_for_op.errors|join:" " }}

{% endif %} +
+ + {% if form.guest_email %} +
+ + {{ form.guest_email }} +

We'll send a confirmation link. The submission disappears in 24 hours if not confirmed. Never shown publicly.

+ {% if form.guest_email.errors %}

{{ form.guest_email.errors|join:" " }}

{% endif %} +
+ {% endif %} + +
+ Cancel + +
+ +
+ + +{% endblock %}