Add detail page
This commit is contained in:
@@ -8,8 +8,8 @@ urlpatterns = [
|
|||||||
path("", views.IndexView.as_view(), name="index"),
|
path("", views.IndexView.as_view(), name="index"),
|
||||||
path("my-prints/", views.MyPrintsView.as_view(), name="my_prints"),
|
path("my-prints/", views.MyPrintsView.as_view(), name="my_prints"),
|
||||||
path("p/<slug:slug>/confirm/<str:token>/", views.ConfirmEmailView.as_view(), name="confirm"),
|
path("p/<slug:slug>/confirm/<str:token>/", views.ConfirmEmailView.as_view(), name="confirm"),
|
||||||
|
path("p/<slug:slug>/", views.SubmissionDetailView.as_view(), name="detail"),
|
||||||
# Routes to be added as features land (see plan.md Section 7):
|
# 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>/status/", views.SubmissionStatusFragment.as_view(), name="status"),
|
||||||
# path("p/<slug:slug>/resend/", views.ResendConfirmationView.as_view(), name="resend"),
|
# path("p/<slug:slug>/resend/", views.ResendConfirmationView.as_view(), name="resend"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from django.db.models import Count, Q
|
|||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.generic import ListView
|
from django.views.generic import DetailView, ListView
|
||||||
|
|
||||||
from apps.submissions.models import Submission, VerifiedEmail
|
from apps.submissions.models import Submission, VerifiedEmail
|
||||||
|
|
||||||
@@ -60,6 +60,50 @@ class IndexView(ListView):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionDetailView(DetailView):
|
||||||
|
"""Per-submission detail page at `/p/<slug>/` (plan.md §7.4 step 7).
|
||||||
|
|
||||||
|
Two access tiers, gated on whether the viewer owns the row:
|
||||||
|
|
||||||
|
- **Anyone with the slug** sees a minimal card -- slug, status badge,
|
||||||
|
age. That's it. Keeps the URL safe to share, gives anonymous /
|
||||||
|
non-owner visitors enough to confirm they're looking at the right
|
||||||
|
submission without leaking source URLs, uploaded filenames,
|
||||||
|
operator notes, or the submitter's notes-to-operator.
|
||||||
|
- **Logged-in owner only** (`submission.submitted_by == request.user`)
|
||||||
|
sees the full demo/detail-completed.html layout: status banner,
|
||||||
|
source card, operator notes, the user's own notes_for_op, details
|
||||||
|
sidebar with submitter / created / closed timestamps.
|
||||||
|
|
||||||
|
Guests never see the owner view -- they aren't logged in by
|
||||||
|
definition, so condition (1) fails. They can still navigate to /p/X/
|
||||||
|
via the URL we email them, they just get the minimal card. Once
|
||||||
|
operator admin actions get wired, we'll grow a `?token=` fast-path
|
||||||
|
for guests to view their own row, but that's plan.md §7.6 territory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model = Submission
|
||||||
|
template_name = "dashboard/detail.html"
|
||||||
|
context_object_name = "submission"
|
||||||
|
slug_url_kwarg = "slug"
|
||||||
|
slug_field = "slug"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().select_related(
|
||||||
|
"submitted_by", "requested_filament", "closed_by"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
sub: Submission = self.object
|
||||||
|
ctx["is_owner"] = (
|
||||||
|
self.request.user.is_authenticated
|
||||||
|
and sub.submitted_by_id is not None
|
||||||
|
and sub.submitted_by_id == self.request.user.id
|
||||||
|
)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class ConfirmEmailView(View):
|
class ConfirmEmailView(View):
|
||||||
"""Email-confirmation landing page (plan.md §7.4 step 8).
|
"""Email-confirmation landing page (plan.md §7.4 step 8).
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
"""Outgoing email -- plan.md §7 (state-transition side effects).
|
"""Outgoing email -- plan.md §7 (state-transition side effects).
|
||||||
|
|
||||||
Two public functions:
|
Three public functions, one per dedicated template:
|
||||||
|
|
||||||
send_confirmation_email(submission)
|
send_confirmation_email(submission)
|
||||||
Guest path: token-link emailed immediately after `SubmitView` creates
|
Guest path: token-link emailed immediately after `SubmitView` creates
|
||||||
an `identifying` row. The user must click within 24 h (plan.md §7.6)
|
an `identifying` row. The user must click within 24 h (plan.md §7.6)
|
||||||
or the row is cleaned up.
|
or the row is cleaned up. Template: `emails/confirmation.*`.
|
||||||
|
|
||||||
send_status_update_email(submission, *, previous_status=None)
|
send_verifying_email(submission)
|
||||||
Generic notifier for any state transition the user should know about
|
Fired by the `process_submissions` worker on the `processing ->
|
||||||
(queued, rejected, completed, failed). Callers pick when to fire it
|
verifying` success branch: "auto-checks cleared, awaiting operator
|
||||||
-- typically operator admin actions and the validation worker.
|
review". Template: `emails/verifying.*`.
|
||||||
|
|
||||||
Both delegate to Django's email machinery. The backend is wired in
|
send_rejection_email(submission, *, previous_status=None)
|
||||||
|
Always fired on any transition into `status = rejected`, whether
|
||||||
|
auto (validator failure from `processing`) or operator-driven (admin
|
||||||
|
"Reject" action from `verifying`). The body renders
|
||||||
|
`submission.operator_notes` verbatim so the user sees the same
|
||||||
|
rejection reason in the email as on the public detail page.
|
||||||
|
Template: `emails/rejected.*`.
|
||||||
|
|
||||||
|
All three delegate to Django's email machinery. The backend is wired in
|
||||||
`hamprint/settings/base.py`: Mailtrap via `django-anymail` when
|
`hamprint/settings/base.py`: Mailtrap via `django-anymail` when
|
||||||
`MAILTRAP_API_TOKEN` is present, console when not. Send failures are caught
|
`MAILTRAP_API_TOKEN` is present, console when not. Send failures are caught
|
||||||
+ logged so a flaky transport never blocks the submission flow.
|
+ logged so a flaky transport never blocks the submission flow.
|
||||||
@@ -104,19 +112,30 @@ def send_confirmation_email(sub: Submission) -> bool:
|
|||||||
return _send("confirmation", sub, {"confirm_url": confirm_url})
|
return _send("confirmation", sub, {"confirm_url": confirm_url})
|
||||||
|
|
||||||
|
|
||||||
def send_status_update_email(
|
def send_rejection_email(
|
||||||
sub: Submission, *, previous_status: str | None = None
|
sub: Submission, *, previous_status: str | None = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Notify the submitter that their submission moved to a new state.
|
"""Notify the submitter that their submission was rejected.
|
||||||
|
|
||||||
Pass `previous_status` so the email can render "was X, now Y" when
|
Always fired on any transition into `status = rejected`:
|
||||||
useful; omit it for first-time-ever notifications.
|
- automatic rejection from `processing` (URL/STL validation failure),
|
||||||
|
- operator rejection from `verifying` (admin "Reject" action).
|
||||||
|
|
||||||
|
The email body renders `submission.operator_notes` verbatim -- that's
|
||||||
|
the same string the auto-validator writes ("Automatic rejection: …")
|
||||||
|
or that an operator types when clicking "Reject" in admin, so the
|
||||||
|
user sees one consistent reason across the email + the public detail
|
||||||
|
page.
|
||||||
|
|
||||||
|
`previous_status` is the state we left to land in `rejected`. Useful
|
||||||
|
so the email can subtly distinguish "rejected before a human even
|
||||||
|
looked" (from `processing`) vs. "rejected after operator review"
|
||||||
|
(from `verifying`); both render with the same template.
|
||||||
"""
|
"""
|
||||||
detail_url = f"{settings.SITE_URL}/p/{sub.slug}/"
|
|
||||||
return _send(
|
return _send(
|
||||||
"status_update",
|
"rejected",
|
||||||
sub,
|
sub,
|
||||||
{"detail_url": detail_url, "previous_status": previous_status},
|
{"previous_status": previous_status},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from django.core.management.base import BaseCommand
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.submissions.emails import send_status_update_email, send_verifying_email
|
from apps.submissions.emails import send_rejection_email, send_verifying_email
|
||||||
from apps.submissions.models import Submission
|
from apps.submissions.models import Submission
|
||||||
from apps.submissions.validation import (
|
from apps.submissions.validation import (
|
||||||
ValidationError,
|
ValidationError,
|
||||||
@@ -71,7 +71,7 @@ class Command(BaseCommand):
|
|||||||
# closed_by stays NULL -- the validator did the rejecting,
|
# closed_by stays NULL -- the validator did the rejecting,
|
||||||
# not an operator (plan.md §5 / §7.3).
|
# not an operator (plan.md §5 / §7.3).
|
||||||
sub.save()
|
sub.save()
|
||||||
send_status_update_email(sub, previous_status="processing")
|
send_rejection_email(sub, previous_status="processing")
|
||||||
self.stdout.write(f"rejected {sub.slug}: {exc}")
|
self.stdout.write(f"rejected {sub.slug}: {exc}")
|
||||||
else:
|
else:
|
||||||
sub.status = Submission.Status.VERIFYING
|
sub.status = Submission.Status.VERIFYING
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -69,7 +69,7 @@
|
|||||||
|
|
||||||
<footer class="mt-12 border-t border-slate-200 bg-white">
|
<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">
|
<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>
|
<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 %}
|
{% if user.is_staff %}<p>Operators: <a href="{% url 'admin:index' %}" class="hover:underline">admin panel</a>.</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
210
templates/dashboard/detail.html
Normal file
210
templates/dashboard/detail.html
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load humanize %}
|
||||||
|
|
||||||
|
{% block title %}{{ submission.slug }} — hamprint{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% if not is_owner %}
|
||||||
|
{% comment %}
|
||||||
|
─── Minimal public view ────────────────────────────────────────────────
|
||||||
|
Shown to anonymous visitors AND to authenticated users who don't own
|
||||||
|
the row. Slug, status badge, age -- that's the contract. Anything more
|
||||||
|
could leak the submitter's notes, source URL, uploaded filename, etc.
|
||||||
|
{% endcomment %}
|
||||||
|
<div class="max-w-md mx-auto pt-12 pb-8 text-center">
|
||||||
|
<h1 class="mono text-4xl font-bold tracking-tight text-amber-700 mb-5 break-words">{{ submission.slug }}</h1>
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full {{ submission.status_badge_class }} text-sm font-medium">
|
||||||
|
{{ submission.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-500">Submitted {{ submission.created_at|naturaltime }}</p>
|
||||||
|
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<p class="mt-10 text-xs text-slate-500">
|
||||||
|
This isn't one of your submissions — only limited info is shown publicly.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-10 text-xs text-slate-500">
|
||||||
|
Public view of this submission. <a href="{% url 'account_login' %}" class="text-amber-700 hover:underline">Sign in</a> if this print is yours to see more.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{% comment %}
|
||||||
|
─── Owner view ────────────────────────────────────────────────────────
|
||||||
|
Mirrors demo/detail-completed.html's structure: status banner, header
|
||||||
|
with slug + age, two-column grid with the substantive content on the
|
||||||
|
left and a Details sidebar on the right.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{# Status banner -- one card per state, palette matches the badge. #}
|
||||||
|
{% if submission.status == 'completed' %}
|
||||||
|
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-5 mb-6 flex items-start gap-4">
|
||||||
|
<svg class="w-8 h-8 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>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="font-semibold text-emerald-900">Ready for pickup!</h2>
|
||||||
|
<p class="text-sm text-emerald-900/80 mt-1">Your print is finished and waiting at the hamlab.lt space. See pickup instructions below.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif submission.status == 'printing' %}
|
||||||
|
<div class="rounded-lg border border-orange-200 bg-orange-50 p-5 mb-6 flex items-start gap-4">
|
||||||
|
<svg class="w-8 h-8 text-orange-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/></svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="font-semibold text-orange-900">Currently printing</h2>
|
||||||
|
<p class="text-sm text-orange-900/80 mt-1">An operator is running this print at the hamlab.lt printer right now.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif submission.status == 'queued' %}
|
||||||
|
<div class="rounded-lg border border-blue-200 bg-blue-50 p-5 mb-6 flex items-start gap-4">
|
||||||
|
<svg class="w-8 h-8 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="font-semibold text-blue-900">In the print queue</h2>
|
||||||
|
<p class="text-sm text-blue-900/80 mt-1">An operator approved your submission. Printing starts shortly.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif submission.status == 'verifying' %}
|
||||||
|
<div class="rounded-lg border border-violet-200 bg-violet-50 p-5 mb-6 flex items-start gap-4">
|
||||||
|
<svg class="w-8 h-8 text-violet-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="font-semibold text-violet-900">Awaiting operator review</h2>
|
||||||
|
<p class="text-sm text-violet-900/80 mt-1">Auto-checks cleared. An operator will take a manual look next; you'll get an email when the status changes.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif submission.status == 'processing' %}
|
||||||
|
<div class="rounded-lg border border-slate-300 bg-slate-50 p-5 mb-6 flex items-start gap-4">
|
||||||
|
<svg class="w-8 h-8 text-slate-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="font-semibold text-slate-900">Running automated checks</h2>
|
||||||
|
<p class="text-sm text-slate-700 mt-1">We're validating your file or URL. This usually finishes in under a minute.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif submission.status == 'identifying' %}
|
||||||
|
<div class="rounded-lg border-2 border-yellow-300 bg-yellow-50 p-5 mb-6 flex items-start gap-4">
|
||||||
|
<svg class="w-8 h-8 text-yellow-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"/></svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="font-semibold text-yellow-900">Check your inbox to confirm</h2>
|
||||||
|
<p class="text-sm text-yellow-900/80 mt-1">We sent a confirmation link. Click it within 24 hours or this submission (and any uploaded STL) will be deleted automatically.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif submission.status == 'rejected' %}
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-5 mb-6 flex items-start gap-4">
|
||||||
|
<svg class="w-8 h-8 text-red-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 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd"/></svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="font-semibold text-red-900">Submission rejected</h2>
|
||||||
|
<p class="text-sm text-red-900/80 mt-1">{% if submission.operator_notes %}See the reason below.{% else %}No reason was recorded.{% endif %} This row will be deleted automatically within 24 hours.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif submission.status == 'failed' %}
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-5 mb-6 flex items-start gap-4">
|
||||||
|
<svg class="w-8 h-8 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/></svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="font-semibold text-red-900">Print failed</h2>
|
||||||
|
<p class="text-sm text-red-900/80 mt-1">{% if submission.operator_notes %}The operator left a comment below.{% else %}The operator hasn't left a comment yet.{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<header class="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full {{ submission.status_badge_class }} text-xs font-medium">{{ submission.get_status_display }}</span>
|
||||||
|
<span class="text-xs text-slate-500">Submitted {{ submission.created_at|naturaltime }}</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="mono text-3xl font-bold tracking-tight text-amber-700 break-words">{{ submission.slug }}</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid lg:grid-cols-3 gap-6">
|
||||||
|
<section class="lg:col-span-2 space-y-4">
|
||||||
|
|
||||||
|
{% if submission.operator_notes %}
|
||||||
|
<div class="bg-white border border-slate-200 rounded-lg p-5">
|
||||||
|
<h3 class="font-semibold text-sm uppercase tracking-wide text-slate-500 mb-3">
|
||||||
|
{% if submission.status == 'completed' %}Pickup instructions
|
||||||
|
{% elif submission.status == 'rejected' %}Reason for rejection
|
||||||
|
{% elif submission.status == 'failed' %}Operator comments
|
||||||
|
{% else %}Notes from the operator{% endif %}
|
||||||
|
</h3>
|
||||||
|
<p class="text-slate-800 text-sm whitespace-pre-line">{{ submission.operator_notes }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Source: uploaded .stl OR external URL link #}
|
||||||
|
<div class="bg-white border border-slate-200 rounded-lg p-5">
|
||||||
|
<h3 class="font-semibold text-sm uppercase tracking-wide text-slate-500 mb-3">Source</h3>
|
||||||
|
{% if submission.source_type == 'upload' %}
|
||||||
|
<div class="flex items-center gap-3 p-3 rounded-md bg-slate-50 border border-slate-200">
|
||||||
|
<svg class="w-8 h-8 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-sm break-all">{% if submission.stl_file %}{{ submission.stl_file.name }}{% else %}(file not on disk){% endif %}</p>
|
||||||
|
<p class="text-xs text-slate-500">Raw <span class="mono">.stl</span> upload</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ submission.source_url }}" target="_blank" rel="noopener" class="flex items-center gap-3 p-3 rounded-md bg-slate-50 border border-slate-200 hover:border-amber-400 hover:bg-amber-50/40">
|
||||||
|
<span class="w-10 h-10 rounded-md bg-amber-100 grid place-items-center text-amber-700 font-bold text-xs flex-shrink-0">{{ submission.source_type|slice:":1"|upper }}</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-sm truncate">{{ submission.source_url }}</p>
|
||||||
|
<p class="text-xs text-slate-500">{{ submission.get_source_type_display }} · external link</p>
|
||||||
|
</div>
|
||||||
|
<svg class="w-4 h-4 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
The user's own notes (private from public dashboard, but obviously
|
||||||
|
visible to the user themselves).
|
||||||
|
{% endcomment %}
|
||||||
|
{% if submission.notes_for_op %}
|
||||||
|
<div class="bg-white border border-slate-200 rounded-lg p-5">
|
||||||
|
<h3 class="font-semibold text-sm uppercase tracking-wide text-slate-500 mb-2">Your notes to the operator</h3>
|
||||||
|
<p class="text-slate-700 text-sm whitespace-pre-line">{{ submission.notes_for_op }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="space-y-4">
|
||||||
|
<div class="bg-white border border-slate-200 rounded-lg p-5 text-sm">
|
||||||
|
<h3 class="font-semibold text-sm uppercase tracking-wide text-slate-500 mb-3">Details</h3>
|
||||||
|
<dl class="space-y-2">
|
||||||
|
<div class="flex justify-between gap-3">
|
||||||
|
<dt class="text-slate-500">Source</dt>
|
||||||
|
<dd class="text-right">{{ submission.source_label }}</dd>
|
||||||
|
</div>
|
||||||
|
{% if submission.requested_filament %}
|
||||||
|
<div class="flex justify-between gap-3">
|
||||||
|
<dt class="text-slate-500">Filament</dt>
|
||||||
|
<dd class="text-right">{{ submission.requested_filament.display_label }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex justify-between gap-3">
|
||||||
|
<dt class="text-slate-500">Submitter</dt>
|
||||||
|
<dd class="text-right">{% if submission.submitted_by %}{{ submission.submitted_by.get_username }}{% else %}Guest{% endif %}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-3">
|
||||||
|
<dt class="text-slate-500">Created</dt>
|
||||||
|
<dd class="text-right">{{ submission.created_at|naturaltime }}</dd>
|
||||||
|
</div>
|
||||||
|
{% if submission.closed_at %}
|
||||||
|
<div class="flex justify-between gap-3">
|
||||||
|
<dt class="text-slate-500">Closed</dt>
|
||||||
|
<dd class="text-right">{{ submission.closed_at|naturaltime }}{% if submission.closed_by %}, by <span class="font-medium">{{ submission.closed_by.get_username }}</span>{% endif %}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="{% url 'dashboard:index' %}" class="text-sm text-slate-500 hover:underline">← Back to dashboard</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
{% for sub in submissions %}
|
{% for sub in submissions %}
|
||||||
<tr class="hover:bg-slate-50">
|
<tr class="hover:bg-slate-50">
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<span class="mono text-amber-700 font-medium">{{ sub.slug }}</span>
|
<a href="{% url 'dashboard:detail' sub.slug %}" class="mono text-amber-700 hover:underline font-medium">{{ sub.slug }}</a>
|
||||||
{% if user.is_authenticated and sub.submitted_by_id == user.id %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
{% for sub in submissions %}
|
{% for sub in submissions %}
|
||||||
<tr class="hover:bg-slate-50">
|
<tr class="hover:bg-slate-50">
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<span class="mono text-amber-700 font-medium">{{ sub.slug }}</span>
|
<a href="{% url 'dashboard:detail' sub.slug %}" class="mono text-amber-700 hover:underline font-medium">{{ sub.slug }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-slate-600">{{ sub.source_label }}</td>
|
<td class="px-4 py-3 text-slate-600">{{ sub.source_label }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user