App v0.1
This commit is contained in:
15
apps/dashboard/urls.py
Normal file
15
apps/dashboard/urls.py
Normal file
@@ -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/<slug:slug>/", views.SubmissionDetailView.as_view(), name="detail"),
|
||||
# path("p/<slug:slug>/status/", views.SubmissionStatusFragment.as_view(), name="status"),
|
||||
# path("p/<slug:slug>/confirm/<str:token>/", views.ConfirmEmailView.as_view(), name="confirm"),
|
||||
# path("p/<slug:slug>/resend/", views.ResendConfirmationView.as_view(), name="resend"),
|
||||
]
|
||||
@@ -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=<value>`; 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
|
||||
|
||||
@@ -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(
|
||||
'<span title="{0}" style="display:inline-block;width:1.25rem;height:1.25rem;border-radius:0.25rem;border:1px solid #cbd5e1;background:{0};"></span>',
|
||||
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"
|
||||
|
||||
152
apps/submissions/forms.py
Normal file
152
apps/submissions/forms.py
Normal file
@@ -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/<name>:` 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 <form> 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
|
||||
193
apps/submissions/migrations/0001_initial.py
Normal file
193
apps/submissions/migrations/0001_initial.py
Normal file
@@ -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",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
9
apps/submissions/urls.py
Normal file
9
apps/submissions/urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "submissions"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.SubmitView.as_view(), name="create"),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
9
assets/tailwind.source.css
Normal file
9
assets/tailwind.source.css
Normal file
@@ -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";
|
||||
@@ -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"
|
||||
|
||||
@@ -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")),
|
||||
]
|
||||
|
||||
2
static/css/tailwind.css
Normal file
2
static/css/tailwind.css
Normal file
File diff suppressed because one or more lines are too long
45
templates/account/login.html
Normal file
45
templates/account/login.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Sign in — hamprint{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto py-8">
|
||||
<div class="bg-white border border-slate-200 rounded-lg p-8 shadow-sm">
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-grid place-items-center w-12 h-12 rounded-lg bg-amber-500 text-white font-bold text-xl mb-3">h</div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Sign in to hamprint</h1>
|
||||
<p class="text-slate-600 mt-2 text-sm">
|
||||
Optional — you can also
|
||||
<a href="{% url 'submissions:create' %}" class="text-amber-700 font-medium hover:underline">submit as a guest</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% 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 %}
|
||||
<a href="{% url 'google_login' %}{% if request.GET.next %}?next={{ request.GET.next|urlencode }}{% endif %}"
|
||||
class="flex items-center justify-center gap-3 w-full px-4 py-3 rounded-md border border-slate-300 bg-white hover:bg-slate-50 text-slate-900 font-medium shadow-sm">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.75h3.57c2.08-1.92 3.28-4.74 3.28-8.07z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.75c-.99.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.12A6.97 6.97 0 015.46 12c0-.74.13-1.45.36-2.12V7.04H2.18A10.99 10.99 0 001 12c0 1.77.42 3.45 1.18 4.96l3.66-2.84z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.04l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z"/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</a>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a href="{% url 'submissions:create' %}" class="text-sm text-slate-600 hover:underline">No thanks, just submit as a guest →</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-slate-200 text-xs text-slate-500 space-y-2">
|
||||
<p class="flex items-start gap-2"><span class="text-emerald-600 mt-0.5">✓</span> Signed-in prints skip email confirmation.</p>
|
||||
<p class="flex items-start gap-2"><span class="text-emerald-600 mt-0.5">✓</span> See all your past prints in one place.</p>
|
||||
<p class="flex items-start gap-2"><span class="text-emerald-600 mt-0.5">✓</span> We don't share your email, ever.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
53
templates/base.html
Normal file
53
templates/base.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% load tailwind_cli %}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}hamprint — public 3D print dashboard{% endblock %}</title>
|
||||
{% tailwind_css %}
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
</style>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-50 text-slate-900">
|
||||
|
||||
<nav class="border-b border-slate-200 bg-white">
|
||||
<div class="mx-auto max-w-6xl px-4 py-3 flex items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<span class="inline-grid place-items-center w-8 h-8 rounded-md bg-amber-500 text-white font-bold">h</span>
|
||||
<span class="font-bold text-lg tracking-tight">hamprint</span>
|
||||
<span class="hidden sm:inline text-xs text-slate-500 ml-1">· hamlab.lt</span>
|
||||
</a>
|
||||
<div class="flex items-center gap-1">
|
||||
<a href="/" class="px-3 py-1.5 text-sm rounded-md {% if request.path == '/' %}text-slate-900 bg-slate-100 font-medium{% else %}text-slate-700 hover:bg-slate-100{% endif %}">Dashboard</a>
|
||||
{% if user.is_authenticated %}
|
||||
<a href="{% url 'dashboard:my_prints' %}" class="px-3 py-1.5 text-sm rounded-md {% if request.resolver_match.view_name == 'dashboard:my_prints' %}text-slate-900 bg-slate-100 font-medium{% else %}text-slate-700 hover:bg-slate-100{% endif %}">My prints</a>
|
||||
{% 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>
|
||||
{% if user.is_authenticated %}
|
||||
<div class="ml-2 flex items-center gap-2 px-2 py-1 rounded-md hover:bg-slate-100">
|
||||
<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>
|
||||
</div>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="mx-auto max-w-6xl px-4 py-8">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="mt-12 border-t border-slate-200 bg-white">
|
||||
<div class="mx-auto max-w-6xl px-4 py-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm text-slate-500">
|
||||
<p>A community service of <a href="https://hamlab.lt" class="font-medium text-slate-700 hover:underline">hamlab.lt</a></p>
|
||||
{% if user.is_staff %}<p>Operators: <a href="{% url 'admin:index' %}" class="hover:underline">admin panel</a>.</p>{% endif %}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
105
templates/dashboard/index.html
Normal file
105
templates/dashboard/index.html
Normal file
@@ -0,0 +1,105 @@
|
||||
{% extends "base.html" %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}Print dashboard — hamprint{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold tracking-tight">Print dashboard</h1>
|
||||
<p class="text-slate-600 mt-1">Every public print job submitted to the hamlab.lt 3d printers. Look up your submission by its codename. Prints are <b>pickup-only</b>.</p>
|
||||
</header>
|
||||
|
||||
{% 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 %}
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<a href="?" class="px-3 py-1.5 text-sm rounded-full border {% if not active_status %}border-slate-900 bg-slate-900 text-white{% else %}border-slate-300 bg-white text-slate-700 hover:bg-slate-100{% endif %}">
|
||||
All <span class="{% if not active_status %}opacity-70{% else %}text-slate-400{% endif %}">{{ counts.total }}</span>
|
||||
</a>
|
||||
<a href="?status=verifying" class="px-3 py-1.5 text-sm rounded-full border {% if active_status == 'verifying' %}border-slate-900 bg-slate-900 text-white{% else %}border-slate-300 bg-white text-slate-700 hover:bg-slate-100{% endif %}">
|
||||
Verifying <span class="{% if active_status == 'verifying' %}opacity-70{% else %}text-slate-400{% endif %}">{{ counts.verifying }}</span>
|
||||
</a>
|
||||
<a href="?status=queued" class="px-3 py-1.5 text-sm rounded-full border {% if active_status == 'queued' %}border-slate-900 bg-slate-900 text-white{% else %}border-slate-300 bg-white text-slate-700 hover:bg-slate-100{% endif %}">
|
||||
Queued <span class="{% if active_status == 'queued' %}opacity-70{% else %}text-slate-400{% endif %}">{{ counts.queued }}</span>
|
||||
</a>
|
||||
<a href="?status=printing" class="px-3 py-1.5 text-sm rounded-full border {% if active_status == 'printing' %}border-slate-900 bg-slate-900 text-white{% else %}border-slate-300 bg-white text-slate-700 hover:bg-slate-100{% endif %}">
|
||||
Printing <span class="{% if active_status == 'printing' %}opacity-70{% else %}text-slate-400{% endif %}">{{ counts.printing }}</span>
|
||||
</a>
|
||||
<a href="?status=completed" class="px-3 py-1.5 text-sm rounded-full border {% if active_status == 'completed' %}border-slate-900 bg-slate-900 text-white{% else %}border-slate-300 bg-white text-slate-700 hover:bg-slate-100{% endif %}">
|
||||
Completed <span class="{% if active_status == 'completed' %}opacity-70{% else %}text-slate-400{% endif %}">{{ counts.completed }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if submissions %}
|
||||
<div class="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 border-b border-slate-200 text-slate-600">
|
||||
<tr class="text-left">
|
||||
<th class="px-4 py-2.5 font-medium">Codename</th>
|
||||
<th class="px-4 py-2.5 font-medium">Status</th>
|
||||
<th class="px-4 py-2.5 font-medium hidden sm:table-cell">Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for sub in submissions %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3">
|
||||
<span class="mono text-amber-700 font-medium">{{ sub.slug }}</span>
|
||||
{% if user.is_authenticated and sub.submitted_by_id == user.id %}
|
||||
<span class="ml-2 inline-flex items-center px-1.5 py-0.5 rounded bg-amber-100 text-amber-900 text-[10px] font-semibold uppercase tracking-wide" title="You submitted this print">yours</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full {{ sub.status_badge_class }} text-xs font-medium">{{ sub.get_status_display }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 hidden sm:table-cell text-slate-500">{{ sub.created_at|naturaltime }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<div class="flex items-center justify-between mt-4 text-sm">
|
||||
<p class="text-slate-500">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
· {{ page_obj.paginator.count }} total
|
||||
</p>
|
||||
<div class="flex items-center gap-1">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?{% if active_status %}status={{ active_status }}&{% endif %}page={{ page_obj.previous_page_number }}" class="px-3 py-1.5 rounded-md border border-slate-300 bg-white text-slate-700 hover:bg-slate-100">← Previous</a>
|
||||
{% else %}
|
||||
<span class="px-3 py-1.5 rounded-md border border-slate-200 text-slate-400 cursor-not-allowed">← Previous</span>
|
||||
{% endif %}
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?{% if active_status %}status={{ active_status }}&{% endif %}page={{ page_obj.next_page_number }}" class="px-3 py-1.5 rounded-md border border-slate-300 bg-white text-slate-700 hover:bg-slate-100">Next →</a>
|
||||
{% else %}
|
||||
<span class="px-3 py-1.5 rounded-md border border-slate-200 text-slate-400 cursor-not-allowed">Next →</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-8 text-center">
|
||||
<p class="text-slate-700 font-medium">
|
||||
{% if active_status %}No submissions in <span class="lowercase">{{ active_status }}</span> right now.{% else %}No submissions yet.{% endif %}
|
||||
</p>
|
||||
<p class="text-slate-500 text-sm mt-1">
|
||||
{% 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.
|
||||
</p>
|
||||
<a href="{% url 'submissions:create' %}" class="inline-block mt-5 px-4 py-2 rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium text-sm">+ Submit a print</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not user.is_authenticated %}
|
||||
<div class="mt-10 rounded-lg border border-amber-200 bg-amber-50 p-4 flex items-start gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-amber-500 text-white grid place-items-center flex-shrink-0 font-bold">?</div>
|
||||
<div class="text-sm">
|
||||
<p class="font-semibold text-amber-900">Don't have an account?</p>
|
||||
<p class="text-amber-800/90 mt-0.5">You don't need one. Just hit <a href="{% url 'submissions:create' %}" class="underline font-medium">Submit a print</a>, give us an email, and we'll send you a codename to track your job.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
85
templates/dashboard/my_prints.html
Normal file
85
templates/dashboard/my_prints.html
Normal file
@@ -0,0 +1,85 @@
|
||||
{% extends "base.html" %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}My prints — hamprint{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="mb-6 flex items-end justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">My prints</h1>
|
||||
<p class="text-slate-600 mt-1">
|
||||
All prints submitted with
|
||||
<span class="font-medium text-slate-800">{{ user.email|default:user.get_username }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-sm text-slate-500">
|
||||
<span class="font-medium text-slate-900">{{ counts.total }}</span> total
|
||||
{% if counts.queued %}· <span class="font-medium text-blue-700">{{ counts.queued }}</span> queued{% endif %}
|
||||
{% if counts.printing %}· <span class="font-medium text-orange-700">{{ counts.printing }}</span> printing{% endif %}
|
||||
{% if counts.completed %}· <span class="font-medium text-emerald-700">{{ counts.completed }}</span> completed{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if submissions %}
|
||||
<div class="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 border-b border-slate-200 text-slate-600">
|
||||
<tr class="text-left">
|
||||
<th class="px-4 py-2.5 font-medium">Codename</th>
|
||||
<th class="px-4 py-2.5 font-medium">Source</th>
|
||||
<th class="px-4 py-2.5 font-medium">Status</th>
|
||||
<th class="px-4 py-2.5 font-medium hidden sm:table-cell">Submitted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for sub in submissions %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3">
|
||||
<span class="mono text-amber-700 font-medium">{{ sub.slug }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ sub.source_label }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full {{ sub.status_badge_class }} text-xs font-medium">
|
||||
{{ sub.get_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 hidden sm:table-cell text-slate-500">{{ sub.created_at|naturaltime }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<div class="flex items-center justify-between mt-4 text-sm">
|
||||
<p class="text-slate-500">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
· {{ page_obj.paginator.count }} total
|
||||
</p>
|
||||
<div class="flex items-center gap-1">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}" class="px-3 py-1.5 rounded-md border border-slate-300 bg-white text-slate-700 hover:bg-slate-100">← Previous</a>
|
||||
{% else %}
|
||||
<span class="px-3 py-1.5 rounded-md border border-slate-200 text-slate-400 cursor-not-allowed">← Previous</span>
|
||||
{% endif %}
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}" class="px-3 py-1.5 rounded-md border border-slate-300 bg-white text-slate-700 hover:bg-slate-100">Next →</a>
|
||||
{% else %}
|
||||
<span class="px-3 py-1.5 rounded-md border border-slate-200 text-slate-400 cursor-not-allowed">Next →</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-8 text-center">
|
||||
<p class="text-slate-700 font-medium">No prints yet.</p>
|
||||
<p class="text-slate-500 text-sm mt-1">Once you submit one, it'll show up here.</p>
|
||||
<a href="{% url 'submissions:create' %}" class="inline-block mt-5 px-4 py-2 rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium text-sm">+ Submit a print</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-6 flex items-center justify-between text-sm">
|
||||
<a href="{% url 'submissions:create' %}" class="text-amber-700 hover:underline font-medium">+ Submit another print</a>
|
||||
<a href="{% url 'account_logout' %}" class="text-slate-500 hover:underline">Sign out</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
189
templates/submissions/submit.html
Normal file
189
templates/submissions/submit.html
Normal file
@@ -0,0 +1,189 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Submit a print — hamprint{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
/* Show/hide source-input panes based on the radio selection. The radios
|
||||
live inside a grid <div> while the panes are siblings of the grid, so a
|
||||
plain `~` combinator never matches. `fieldset:has(...)` reaches across
|
||||
the grid and selects the right pane regardless of nesting depth. */
|
||||
.src-pane { display: none; }
|
||||
fieldset:has(#src-upload:checked) .src-pane[data-pane="upload"],
|
||||
fieldset:has(#src-printables:checked) .src-pane[data-pane="url"],
|
||||
fieldset:has(#src-makerworld:checked) .src-pane[data-pane="url"],
|
||||
fieldset:has(#src-thingiverse:checked) .src-pane[data-pane="url"] { display: block; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold tracking-tight">Submit a print</h1>
|
||||
<p class="text-slate-600 mt-1">Anyone can submit a job. We'll send you a codename to follow it on the dashboard.</p>
|
||||
</header>
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 mb-6 flex items-start gap-3 text-sm">
|
||||
<svg class="w-5 h-5 text-emerald-600 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd"/></svg>
|
||||
<p class="text-emerald-900">Signed in as <span class="font-medium">{{ user.email|default:user.get_username }}</span>. No email confirmation needed.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4 mb-6 flex items-start gap-3 text-sm">
|
||||
<span class="px-2 py-0.5 rounded-md bg-slate-100 text-slate-700 text-xs font-medium uppercase tracking-wide">Guest</span>
|
||||
<p class="text-slate-700">You're submitting anonymously. We'll email you a confirmation link — click it within <span class="font-medium">24 hours</span> to put your print in the queue. <a href="{% url 'account_login' %}" class="text-amber-700 font-medium hover:underline">Sign in with Google</a> to skip this step.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-6 bg-white border border-slate-200 rounded-lg p-6">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||
{{ form.non_field_errors|join:" " }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% comment %}
|
||||
Source-type radio cards. Hardcoded (not looped) so Tailwind's scanner
|
||||
sees the literal `peer/<name>` and `peer-checked/<name>:` class strings
|
||||
and emits the matching CSS rules.
|
||||
{% endcomment %}
|
||||
<fieldset>
|
||||
<legend class="block text-sm font-medium text-slate-900 mb-2">Where is the model coming from? <span class="text-red-500">*</span></legend>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-4">
|
||||
<input type="radio" name="{{ form.source_type.html_name }}" id="src-upload" value="upload" class="hidden peer/upload" {% if form.source_type.value == "upload" or not form.source_type.value %}checked{% endif %}>
|
||||
<label for="src-upload" class="cursor-pointer border border-slate-300 rounded-md px-3 py-2 text-center text-sm hover:border-amber-500 peer-checked/upload:border-amber-500 peer-checked/upload:bg-amber-50 peer-checked/upload:text-amber-900 peer-checked/upload:font-medium">.stl file</label>
|
||||
|
||||
<input type="radio" name="{{ form.source_type.html_name }}" id="src-printables" value="printables" class="hidden peer/printables" {% if form.source_type.value == "printables" %}checked{% endif %}>
|
||||
<label for="src-printables" class="cursor-pointer border border-slate-300 rounded-md px-3 py-2 text-center text-sm hover:border-amber-500 peer-checked/printables:border-amber-500 peer-checked/printables:bg-amber-50 peer-checked/printables:text-amber-900 peer-checked/printables:font-medium">Printables</label>
|
||||
|
||||
<input type="radio" name="{{ form.source_type.html_name }}" id="src-makerworld" value="makerworld" class="hidden peer/makerworld" {% if form.source_type.value == "makerworld" %}checked{% endif %}>
|
||||
<label for="src-makerworld" class="cursor-pointer border border-slate-300 rounded-md px-3 py-2 text-center text-sm hover:border-amber-500 peer-checked/makerworld:border-amber-500 peer-checked/makerworld:bg-amber-50 peer-checked/makerworld:text-amber-900 peer-checked/makerworld:font-medium">MakerWorld</label>
|
||||
|
||||
<input type="radio" name="{{ form.source_type.html_name }}" id="src-thingiverse" value="thingiverse" class="hidden peer/thingiverse" {% if form.source_type.value == "thingiverse" %}checked{% endif %}>
|
||||
<label for="src-thingiverse" class="cursor-pointer border border-slate-300 rounded-md px-3 py-2 text-center text-sm hover:border-amber-500 peer-checked/thingiverse:border-amber-500 peer-checked/thingiverse:bg-amber-50 peer-checked/thingiverse:text-amber-900 peer-checked/thingiverse:font-medium">Thingiverse</label>
|
||||
</div>
|
||||
|
||||
{% comment %}
|
||||
Upload pane. The native file input stays sr-only and is triggered by
|
||||
the wrapping <label> dropzone. After selection, the prompt text is
|
||||
swapped for the filename + size via the inline script below.
|
||||
{% endcomment %}
|
||||
<div class="src-pane" data-pane="upload">
|
||||
<label class="block cursor-pointer">
|
||||
<div class="border-2 border-dashed border-slate-300 rounded-md p-6 text-center hover:border-amber-500 hover:bg-amber-50/40 transition">
|
||||
<svg class="w-8 h-8 mx-auto text-slate-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0L9 12.75M12 9.75l3 3M3 17.25V18a2.25 2.25 0 002.25 2.25h13.5A2.25 2.25 0 0021 18v-.75"/></svg>
|
||||
<p data-stl-prompt class="mt-2 text-sm font-medium text-slate-700">Drop your .stl here or click to browse</p>
|
||||
<p data-stl-filename class="hidden mt-2 text-sm font-medium text-amber-700"></p>
|
||||
<p class="text-xs text-slate-500 mt-1">Raw <span class="mono">.stl</span> only, max 4 MB. No <span class="mono">.3mf</span>, <span class="mono">.gcode</span>, <span class="mono">.zip</span>, etc.</p>
|
||||
</div>
|
||||
{{ form.stl_file }}
|
||||
</label>
|
||||
{% if form.stl_file.errors %}<p class="text-xs text-red-600 mt-1">{{ form.stl_file.errors|join:" " }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
{% comment %}
|
||||
Shared URL pane. The label, placeholder, and help text are rewritten
|
||||
per source-type by the inline script below so the user gets host-
|
||||
specific guidance even though there's only one real <input>.
|
||||
{% endcomment %}
|
||||
<div class="src-pane" data-pane="url">
|
||||
<label for="{{ form.source_url.id_for_label }}" data-url-label class="block text-sm font-medium text-slate-700 mb-1">Model URL</label>
|
||||
{{ form.source_url }}
|
||||
<p data-url-help class="text-xs text-slate-500 mt-1">Must be a link on <span class="mono">printables.com</span>, <span class="mono">makerworld.com</span>, or <span class="mono">thingiverse.com</span> matching your selection above.</p>
|
||||
{% if form.source_url.errors %}<p class="text-xs text-red-600 mt-1">{{ form.source_url.errors|join:" " }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-slate-500 mt-3">Got something from elsewhere? Download the <span class="mono">.stl</span> and upload it as a file.</p>
|
||||
{% if form.source_type.errors %}<p class="text-xs text-red-600 mt-1">{{ form.source_type.errors|join:" " }}</p>{% endif %}
|
||||
</fieldset>
|
||||
|
||||
{# Filament #}
|
||||
<div>
|
||||
<label for="{{ form.requested_filament.id_for_label }}" class="block text-sm font-medium text-slate-900 mb-1">Filament</label>
|
||||
{{ form.requested_filament }}
|
||||
<p class="text-xs text-slate-500 mt-1">Only filaments currently loaded at hamlab.lt are listed. The list is curated by operators — out-of-stock options are hidden.</p>
|
||||
{% if form.requested_filament.errors %}<p class="text-xs text-red-600 mt-1">{{ form.requested_filament.errors|join:" " }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
{# Notes (private) #}
|
||||
<div>
|
||||
<label for="{{ form.notes_for_op.id_for_label }}" class="block text-sm font-medium text-slate-900 mb-1">
|
||||
Notes for the operator
|
||||
<span class="ml-1 inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-slate-100 text-slate-600 text-[10px] font-medium uppercase tracking-wide align-middle">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
|
||||
Private
|
||||
</span>
|
||||
</label>
|
||||
{{ form.notes_for_op }}
|
||||
<p class="text-xs text-slate-500 mt-1">Only the operator sees these — they're not shown on your public submission page.</p>
|
||||
{% if form.notes_for_op.errors %}<p class="text-xs text-red-600 mt-1">{{ form.notes_for_op.errors|join:" " }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
{% if form.guest_email %}
|
||||
<div class="border-t border-slate-200 pt-6">
|
||||
<label for="{{ form.guest_email.id_for_label }}" class="block text-sm font-medium text-slate-900 mb-1">Your email <span class="text-red-500">*</span></label>
|
||||
{{ form.guest_email }}
|
||||
<p class="text-xs text-slate-500 mt-1">We'll send a confirmation link. The submission disappears in 24 hours if not confirmed. Never shown publicly.</p>
|
||||
{% if form.guest_email.errors %}<p class="text-xs text-red-600 mt-1">{{ form.guest_email.errors|join:" " }}</p>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center justify-end gap-3 border-t border-slate-200 pt-6">
|
||||
<a href="{% url 'dashboard:index' %}" class="px-4 py-2 text-sm rounded-md text-slate-700 hover:bg-slate-100">Cancel</a>
|
||||
<button type="submit" class="px-4 py-2 text-sm rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium">Submit print</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Filename feedback: swap "Drop your .stl here" for the selected file's name
|
||||
// + size as soon as the user picks something via the hidden file input.
|
||||
(function() {
|
||||
var input = document.getElementById("{{ form.stl_file.id_for_label }}");
|
||||
var prompt = document.querySelector("[data-stl-prompt]");
|
||||
var filename = document.querySelector("[data-stl-filename]");
|
||||
if (!input || !prompt || !filename) return;
|
||||
input.addEventListener("change", function(e) {
|
||||
var file = e.target.files && e.target.files[0];
|
||||
if (file) {
|
||||
prompt.classList.add("hidden");
|
||||
filename.classList.remove("hidden");
|
||||
filename.textContent = "✓ " + file.name + " (" + Math.round(file.size / 1024) + " KB)";
|
||||
} else {
|
||||
prompt.classList.remove("hidden");
|
||||
filename.classList.add("hidden");
|
||||
filename.textContent = "";
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// URL-pane copy: rewrite the label, the input's placeholder, and the help
|
||||
// text per source-type so the user sees host-specific guidance.
|
||||
(function() {
|
||||
var COPY = {
|
||||
printables: { label: "Printables.com URL", ph: "https://www.printables.com/model/…", help: "Must be a printables.com link." },
|
||||
makerworld: { label: "MakerWorld URL", ph: "https://makerworld.com/en/models/…", help: "Must be a makerworld.com link." },
|
||||
thingiverse: { label: "Thingiverse URL", ph: "https://www.thingiverse.com/thing:…", help: "Must be a thingiverse.com link." }
|
||||
};
|
||||
var labelEl = document.querySelector("[data-url-label]");
|
||||
var inputEl = document.getElementById("{{ form.source_url.id_for_label }}");
|
||||
var helpEl = document.querySelector("[data-url-help]");
|
||||
|
||||
function apply(sourceType) {
|
||||
var c = COPY[sourceType];
|
||||
if (!c) return;
|
||||
if (labelEl) labelEl.textContent = c.label;
|
||||
if (inputEl) inputEl.placeholder = c.ph;
|
||||
if (helpEl) helpEl.textContent = c.help;
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[name="{{ form.source_type.html_name }}"]').forEach(function(radio) {
|
||||
radio.addEventListener("change", function(e) { apply(e.target.value); });
|
||||
if (radio.checked) apply(radio.value);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user