Add better email verification
This commit is contained in:
@@ -8,7 +8,7 @@ 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 ListView
|
||||||
|
|
||||||
from apps.submissions.models import Submission
|
from apps.submissions.models import Submission, VerifiedEmail
|
||||||
|
|
||||||
|
|
||||||
class IndexView(ListView):
|
class IndexView(ListView):
|
||||||
@@ -119,6 +119,12 @@ class ConfirmEmailView(View):
|
|||||||
sub.confirmation_token = ""
|
sub.confirmation_token = ""
|
||||||
sub.save()
|
sub.save()
|
||||||
|
|
||||||
|
# Refresh / record the trust-list entry for this email so subsequent
|
||||||
|
# guest submissions from the same address (or any +tag variant of
|
||||||
|
# it) skip the `identifying` step for the next 30 days (plan.md §6).
|
||||||
|
if sub.guest_email:
|
||||||
|
VerifiedEmail.record_verification(sub.guest_email)
|
||||||
|
|
||||||
messages.success(
|
messages.success(
|
||||||
request,
|
request,
|
||||||
format_html(
|
format_html(
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
|
||||||
from .models import Filament, Submission
|
from .models import Filament, Submission, VerifiedEmail
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(VerifiedEmail)
|
||||||
|
class VerifiedEmailAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("email", "validated_at")
|
||||||
|
search_fields = ("email",)
|
||||||
|
readonly_fields = ("validated_at",)
|
||||||
|
ordering = ("-validated_at",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Filament)
|
@admin.register(Filament)
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ class SubmissionForm(forms.ModelForm):
|
|||||||
|
|
||||||
def __init__(self, *args, user=None, **kwargs):
|
def __init__(self, *args, user=None, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
# Stash for `clean()` -- the per-email cap needs to know whether the
|
||||||
|
# incoming submission is OAuth (user.email) or guest (guest_email).
|
||||||
|
self.user = user
|
||||||
|
|
||||||
# Filament dropdown shows only operator-curated, currently-loaded rows.
|
# Filament dropdown shows only operator-curated, currently-loaded rows.
|
||||||
self.fields["requested_filament"].queryset = Filament.objects.filter(
|
self.fields["requested_filament"].queryset = Filament.objects.filter(
|
||||||
@@ -118,6 +121,26 @@ class SubmissionForm(forms.ModelForm):
|
|||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned = super().clean()
|
cleaned = super().clean()
|
||||||
|
|
||||||
|
# Per-email cap (plan.md §6). Run this BEFORE the source-type checks
|
||||||
|
# so a user already at quota doesn't get a misleading "pick a source"
|
||||||
|
# error message; the cap is the real reason their submission failed.
|
||||||
|
owner_email = (
|
||||||
|
self.user.email
|
||||||
|
if (self.user and self.user.is_authenticated)
|
||||||
|
else cleaned.get("guest_email")
|
||||||
|
)
|
||||||
|
if owner_email:
|
||||||
|
active = Submission.active_count_for_email(owner_email)
|
||||||
|
cap = Submission.MAX_ACTIVE_SUBMISSIONS_PER_EMAIL
|
||||||
|
if active >= cap:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
f"You already have {active} active submission(s) -- "
|
||||||
|
f"that's the per-email cap of {cap}. Wait for some to "
|
||||||
|
f"finish printing (or be cleaned up after rejection) "
|
||||||
|
f"before submitting another."
|
||||||
|
)
|
||||||
|
|
||||||
source_type = cleaned.get("source_type")
|
source_type = cleaned.get("source_type")
|
||||||
stl_file = cleaned.get("stl_file")
|
stl_file = cleaned.get("stl_file")
|
||||||
source_url = cleaned.get("source_url")
|
source_url = cleaned.get("source_url")
|
||||||
|
|||||||
36
apps/submissions/migrations/0002_verifiedemail.py
Normal file
36
apps/submissions/migrations/0002_verifiedemail.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-14 20:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("submissions", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="VerifiedEmail",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"email",
|
||||||
|
models.EmailField(db_index=True, max_length=254, unique=True),
|
||||||
|
),
|
||||||
|
("verified_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ("-updated_at",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
39
apps/submissions/migrations/0003_backfill_verified_emails.py
Normal file
39
apps/submissions/migrations/0003_backfill_verified_emails.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Backfill `VerifiedEmail` from any historical submission that already had
|
||||||
|
`email_confirmed=True` (i.e. the user clicked the confirmation link before
|
||||||
|
the trust list existed). One-shot; safe to re-run thanks to
|
||||||
|
`update_or_create`."""
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_verified_emails(apps, schema_editor):
|
||||||
|
Submission = apps.get_model("submissions", "Submission")
|
||||||
|
VerifiedEmail = apps.get_model("submissions", "VerifiedEmail")
|
||||||
|
|
||||||
|
# `update_or_create` keeps the migration idempotent.
|
||||||
|
seen: set[str] = set()
|
||||||
|
qs = Submission.objects.filter(email_confirmed=True).exclude(
|
||||||
|
guest_email__isnull=True
|
||||||
|
).exclude(guest_email="").values_list("guest_email", flat=True)
|
||||||
|
for email in qs:
|
||||||
|
email = email.strip().lower()
|
||||||
|
if not email or email in seen:
|
||||||
|
continue
|
||||||
|
seen.add(email)
|
||||||
|
VerifiedEmail.objects.update_or_create(email=email)
|
||||||
|
|
||||||
|
|
||||||
|
def noop_reverse(apps, schema_editor):
|
||||||
|
"""We don't try to undo the backfill -- the trust list is a forward-only
|
||||||
|
derived artefact; rolling back the migration leaves the rows alone."""
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("submissions", "0002_verifiedemail"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(backfill_verified_emails, noop_reverse),
|
||||||
|
]
|
||||||
51
apps/submissions/migrations/0004_email_normalization.py
Normal file
51
apps/submissions/migrations/0004_email_normalization.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Schema changes for the email normalisation / TTL / cap work.
|
||||||
|
|
||||||
|
- `Submission.canonical_email`: new indexed column populated by
|
||||||
|
`Submission.save()`. Used to count active submissions per email for the
|
||||||
|
10-cap, and to look up the `VerifiedEmail` trust list.
|
||||||
|
- `VerifiedEmail.verified_at` -> `validated_at`: keeps the data, drops the
|
||||||
|
`auto_now_add` so `update_or_create` can roll the timestamp forward on
|
||||||
|
every re-confirmation (rolling 30-day TTL).
|
||||||
|
- `VerifiedEmail.updated_at`: removed -- `validated_at` IS the most recent
|
||||||
|
confirmation timestamp now, no need for a second column.
|
||||||
|
|
||||||
|
The data backfill (populate canonical_email, re-normalise existing
|
||||||
|
VerifiedEmail rows) lives in 0005_normalize_existing_data so this
|
||||||
|
migration stays a clean schema-only change.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("submissions", "0003_backfill_verified_emails"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="submission",
|
||||||
|
name="canonical_email",
|
||||||
|
field=models.EmailField(blank=True, db_index=True, max_length=254),
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="verifiedemail",
|
||||||
|
old_name="verified_at",
|
||||||
|
new_name="validated_at",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="verifiedemail",
|
||||||
|
name="updated_at",
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="verifiedemail",
|
||||||
|
name="validated_at",
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="verifiedemail",
|
||||||
|
options={"ordering": ("-validated_at",)},
|
||||||
|
),
|
||||||
|
]
|
||||||
92
apps/submissions/migrations/0005_normalize_existing_data.py
Normal file
92
apps/submissions/migrations/0005_normalize_existing_data.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""One-shot data backfill for the schema change in 0004:
|
||||||
|
|
||||||
|
1. Populate `Submission.canonical_email` for every existing row by deriving
|
||||||
|
it from `submitted_by.email` (OAuth) or `guest_email` (guest) and
|
||||||
|
running through the same normaliser the live `save()` uses.
|
||||||
|
2. Re-normalise every `VerifiedEmail.email` already in the table. Rows that
|
||||||
|
collapse to the same canonical form are deduped: we keep the row with
|
||||||
|
the most recent `validated_at` and delete the others.
|
||||||
|
|
||||||
|
Defensive: both passes use `update_fields=` and `update_or_create`-style
|
||||||
|
logic so re-running the migration is a no-op once it's been applied.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_email(email):
|
||||||
|
if not email or "@" not in email:
|
||||||
|
return (email or "").lower()
|
||||||
|
local, _, domain = email.lower().rpartition("@")
|
||||||
|
if "+" in local:
|
||||||
|
local = local.split("+", 1)[0]
|
||||||
|
return f"{local}@{domain}"
|
||||||
|
|
||||||
|
|
||||||
|
def forward(apps, schema_editor):
|
||||||
|
Submission = apps.get_model("submissions", "Submission")
|
||||||
|
VerifiedEmail = apps.get_model("submissions", "VerifiedEmail")
|
||||||
|
User = apps.get_model("auth", "User")
|
||||||
|
|
||||||
|
# ---- Submission.canonical_email -----------------------------------------
|
||||||
|
# Pull all related users up front so we don't do an O(N) round-trip
|
||||||
|
# per submission.
|
||||||
|
user_emails = dict(
|
||||||
|
User.objects.exclude(email="").values_list("pk", "email")
|
||||||
|
)
|
||||||
|
to_update = []
|
||||||
|
for sub in Submission.objects.all().only(
|
||||||
|
"pk", "submitted_by_id", "guest_email", "canonical_email"
|
||||||
|
):
|
||||||
|
owner_email = ""
|
||||||
|
if sub.submitted_by_id and user_emails.get(sub.submitted_by_id):
|
||||||
|
owner_email = user_emails[sub.submitted_by_id]
|
||||||
|
elif sub.guest_email:
|
||||||
|
owner_email = sub.guest_email
|
||||||
|
new = _normalize_email(owner_email)
|
||||||
|
if new != sub.canonical_email:
|
||||||
|
sub.canonical_email = new
|
||||||
|
to_update.append(sub)
|
||||||
|
if to_update:
|
||||||
|
Submission.objects.bulk_update(to_update, ["canonical_email"], batch_size=500)
|
||||||
|
|
||||||
|
# ---- VerifiedEmail re-normalisation + dedup ----------------------------
|
||||||
|
# First pass: pick the surviving row per normalised form (most recent
|
||||||
|
# validated_at wins). Delete the losers.
|
||||||
|
survivors: dict[str, tuple[int, object]] = {} # norm -> (pk, validated_at)
|
||||||
|
for row in VerifiedEmail.objects.all().only("pk", "email", "validated_at"):
|
||||||
|
norm = _normalize_email(row.email)
|
||||||
|
if not norm:
|
||||||
|
row.delete()
|
||||||
|
continue
|
||||||
|
prev = survivors.get(norm)
|
||||||
|
if prev is None:
|
||||||
|
survivors[norm] = (row.pk, row.validated_at)
|
||||||
|
else:
|
||||||
|
prev_pk, prev_at = prev
|
||||||
|
if row.validated_at > prev_at:
|
||||||
|
VerifiedEmail.objects.filter(pk=prev_pk).delete()
|
||||||
|
survivors[norm] = (row.pk, row.validated_at)
|
||||||
|
else:
|
||||||
|
row.delete()
|
||||||
|
|
||||||
|
# Second pass: rewrite the surviving row's email to its normalised form
|
||||||
|
# (no-op when already normalised; safe because all duplicates are gone).
|
||||||
|
for norm, (pk, _at) in survivors.items():
|
||||||
|
VerifiedEmail.objects.filter(pk=pk).update(email=norm)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(apps, schema_editor):
|
||||||
|
"""The forward pass is a derived backfill; there's nothing meaningful
|
||||||
|
to undo. Leaving rows alone is the right thing on rollback."""
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("submissions", "0004_email_normalization"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forward, reverse),
|
||||||
|
]
|
||||||
@@ -7,12 +7,38 @@ are documented in plan.md §7.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import namesgenerator
|
import namesgenerator
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.validators import FileExtensionValidator
|
from django.core.validators import FileExtensionValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_email(email: str) -> str:
|
||||||
|
"""Canonicalise an email for per-email accounting (the trust list AND
|
||||||
|
the active-submission cap).
|
||||||
|
|
||||||
|
- Lowercase the entire address.
|
||||||
|
- Strip Gmail-style `+tag` from the local part:
|
||||||
|
`user+anything@host.com` -> `user@host.com`.
|
||||||
|
|
||||||
|
Same return value for `User@Gmail.com`, `user+a@gmail.com`,
|
||||||
|
`user+b@gmail.com`. Returns `""` for falsy / malformed input.
|
||||||
|
"""
|
||||||
|
if not email or "@" not in email:
|
||||||
|
return (email or "").lower()
|
||||||
|
local, _, domain = email.lower().rpartition("@")
|
||||||
|
if "+" in local:
|
||||||
|
local = local.split("+", 1)[0]
|
||||||
|
return f"{local}@{domain}"
|
||||||
|
|
||||||
|
|
||||||
|
# Email trust lasts 30 days from `validated_at`; after that the user has to
|
||||||
|
# click a fresh confirmation link to re-prove inbox ownership.
|
||||||
|
EMAIL_TRUST_TTL = timedelta(days=30)
|
||||||
|
|
||||||
|
|
||||||
def _validate_stl_size(uploaded_file) -> None:
|
def _validate_stl_size(uploaded_file) -> None:
|
||||||
@@ -27,6 +53,61 @@ def _validate_stl_size(uploaded_file) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VerifiedEmail(models.Model):
|
||||||
|
"""An email address that has confirmed at least one hamprint submission.
|
||||||
|
|
||||||
|
Stored **normalised** (lowercased, `+tag` stripped) so
|
||||||
|
`user@gmail.com`, `user+a@gmail.com`, and `user+b@gmail.com` all
|
||||||
|
collapse to a single row.
|
||||||
|
|
||||||
|
Each entry carries a single `validated_at` timestamp. Trust expires
|
||||||
|
after `EMAIL_TRUST_TTL` (30 days); after that the next guest submission
|
||||||
|
from the same address falls back to `identifying` and gets a fresh
|
||||||
|
confirmation link, which on success bumps `validated_at` back to now
|
||||||
|
via `record_verification()`.
|
||||||
|
|
||||||
|
Trade-off worth knowing: anyone who knows a verified address can use
|
||||||
|
it to bypass confirmation for the next 30 days. We accept that for
|
||||||
|
hamlab's small-scale, operator-moderated workflow -- the operator
|
||||||
|
still has to manually approve every submission before it prints.
|
||||||
|
Operators can also revoke an entry via the admin to force the next
|
||||||
|
submission back through the welcome-email flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
email = models.EmailField(unique=True, db_index=True)
|
||||||
|
validated_at = models.DateTimeField(default=timezone.now)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ("-validated_at",)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.email
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_trusted(cls, email: str) -> bool:
|
||||||
|
"""True if `email` (after normalisation) has a fresh trust entry --
|
||||||
|
i.e., `validated_at` is within `EMAIL_TRUST_TTL` of now."""
|
||||||
|
norm = normalize_email(email)
|
||||||
|
if not norm:
|
||||||
|
return False
|
||||||
|
cutoff = timezone.now() - EMAIL_TRUST_TTL
|
||||||
|
return cls.objects.filter(email=norm, validated_at__gte=cutoff).exists()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def record_verification(cls, email: str) -> "VerifiedEmail | None":
|
||||||
|
"""Mark `email` as freshly verified. Normalises first, then
|
||||||
|
`update_or_create`-s with a current `validated_at`. Returns the
|
||||||
|
row (or None if `email` was falsy)."""
|
||||||
|
norm = normalize_email(email)
|
||||||
|
if not norm:
|
||||||
|
return None
|
||||||
|
obj, _ = cls.objects.update_or_create(
|
||||||
|
email=norm,
|
||||||
|
defaults={"validated_at": timezone.now()},
|
||||||
|
)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
class Filament(models.Model):
|
class Filament(models.Model):
|
||||||
"""Operator-curated filament inventory (plan.md §5).
|
"""Operator-curated filament inventory (plan.md §5).
|
||||||
|
|
||||||
@@ -98,6 +179,15 @@ class Submission(models.Model):
|
|||||||
)
|
)
|
||||||
TERMINAL_STATUSES = (Status.COMPLETED, Status.FAILED, Status.REJECTED)
|
TERMINAL_STATUSES = (Status.COMPLETED, Status.FAILED, Status.REJECTED)
|
||||||
|
|
||||||
|
# Per-email rate limit. A single email address (after normalisation --
|
||||||
|
# see `normalize_email`) is allowed at most this many submissions
|
||||||
|
# whose status is NOT in `STATUSES_EXCLUDED_FROM_LIMIT`. The exclusion
|
||||||
|
# is intentional: `printing` jobs are short-lived and operator-driven,
|
||||||
|
# and `rejected` jobs are already cleaned up after 24 h, so neither
|
||||||
|
# should count against the user's quota.
|
||||||
|
MAX_ACTIVE_SUBMISSIONS_PER_EMAIL = 10
|
||||||
|
STATUSES_EXCLUDED_FROM_LIMIT = (Status.PRINTING, Status.REJECTED)
|
||||||
|
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
slug = models.CharField(max_length=64, unique=True, db_index=True)
|
slug = models.CharField(max_length=64, unique=True, db_index=True)
|
||||||
|
|
||||||
@@ -159,6 +249,13 @@ class Submission(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
# Normalised email (lowercased, +tag stripped) of whoever owns this row
|
||||||
|
# -- the OAuth user if `submitted_by` is set, otherwise the guest. Used
|
||||||
|
# for the active-submissions cap + the `VerifiedEmail` trust lookup,
|
||||||
|
# both of which need to treat `user+a@gmail.com` and `user@gmail.com`
|
||||||
|
# as the same mailbox. Populated in `save()`.
|
||||||
|
canonical_email = models.EmailField(blank=True, db_index=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ("-created_at",)
|
ordering = ("-created_at",)
|
||||||
constraints = [
|
constraints = [
|
||||||
@@ -182,11 +279,38 @@ class Submission(models.Model):
|
|||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Auto-generate `slug` on first save so any creation path -- admin,
|
"""Auto-generate `slug` on first save so any creation path -- admin,
|
||||||
`SubmitView`, fixtures, `objects.create()` -- gets a Docker-style
|
`SubmitView`, fixtures, `objects.create()` -- gets a Docker-style
|
||||||
codename without callers having to remember to set one."""
|
codename without callers having to remember to set one. Also keeps
|
||||||
|
`canonical_email` in sync with whichever side (OAuth user / guest)
|
||||||
|
currently owns the row, so the per-email cap and trust list don't
|
||||||
|
depend on the caller remembering to set it."""
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = self._generate_unique_slug()
|
self.slug = self._generate_unique_slug()
|
||||||
|
# Re-derive canonical_email every save: cheap, and survives an
|
||||||
|
# operator flipping `submitted_by` / `guest_email` in admin.
|
||||||
|
owner_email = ""
|
||||||
|
if self.submitted_by_id and self.submitted_by and self.submitted_by.email:
|
||||||
|
owner_email = self.submitted_by.email
|
||||||
|
elif self.guest_email:
|
||||||
|
owner_email = self.guest_email
|
||||||
|
self.canonical_email = normalize_email(owner_email)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def active_count_for_email(cls, email: str) -> int:
|
||||||
|
"""Return how many of this email's submissions count against the
|
||||||
|
`MAX_ACTIVE_SUBMISSIONS_PER_EMAIL` cap.
|
||||||
|
|
||||||
|
Submissions in `STATUSES_EXCLUDED_FROM_LIMIT` (`printing`,
|
||||||
|
`rejected`) are excluded -- they're transient or already-cleaned
|
||||||
|
states that shouldn't pin the user's quota.
|
||||||
|
"""
|
||||||
|
norm = normalize_email(email)
|
||||||
|
if not norm:
|
||||||
|
return 0
|
||||||
|
return cls.objects.filter(canonical_email=norm).exclude(
|
||||||
|
status__in=cls.STATUSES_EXCLUDED_FROM_LIMIT
|
||||||
|
).count()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _generate_unique_slug(cls, max_attempts: int = 16) -> str:
|
def _generate_unique_slug(cls, max_attempts: int = 16) -> str:
|
||||||
"""`namesgenerator.get_random_name` + collision retries in Python
|
"""`namesgenerator.get_random_name` + collision retries in Python
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from django.views.generic import CreateView
|
|||||||
|
|
||||||
from .emails import send_confirmation_email
|
from .emails import send_confirmation_email
|
||||||
from .forms import SubmissionForm
|
from .forms import SubmissionForm
|
||||||
from .models import Submission
|
from .models import Submission, VerifiedEmail
|
||||||
|
|
||||||
|
|
||||||
class SubmitView(CreateView):
|
class SubmitView(CreateView):
|
||||||
@@ -52,6 +52,16 @@ class SubmitView(CreateView):
|
|||||||
submission.guest_email = None
|
submission.guest_email = None
|
||||||
submission.email_confirmed = True
|
submission.email_confirmed = True
|
||||||
submission.status = Submission.Status.PROCESSING
|
submission.status = Submission.Status.PROCESSING
|
||||||
|
elif submission.guest_email and VerifiedEmail.is_trusted(submission.guest_email):
|
||||||
|
# Returning guest: their email is on the trust list AND the
|
||||||
|
# 30-day TTL hasn't lapsed (plan.md §6). Skip `identifying` and
|
||||||
|
# the welcome email entirely -- straight to `processing` like
|
||||||
|
# an OAuth submitter. Email normalisation happens inside
|
||||||
|
# `is_trusted`, so `user+a@gmail.com` and `user@gmail.com`
|
||||||
|
# collapse to the same lookup.
|
||||||
|
submission.submitted_by = None
|
||||||
|
submission.email_confirmed = True
|
||||||
|
submission.status = Submission.Status.PROCESSING
|
||||||
else:
|
else:
|
||||||
submission.submitted_by = None
|
submission.submitted_by = None
|
||||||
# guest_email is already on the form's cleaned_data, ModelForm
|
# guest_email is already on the form's cleaned_data, ModelForm
|
||||||
@@ -102,10 +112,9 @@ class SubmitView(CreateView):
|
|||||||
self.request,
|
self.request,
|
||||||
format_html(
|
format_html(
|
||||||
"Submission <strong class=\"font-mono\">{slug}</strong> created. "
|
"Submission <strong class=\"font-mono\">{slug}</strong> created. "
|
||||||
"We've sent a confirmation link to <strong>{email}</strong> — "
|
"We've sent you a confirmation link — check your inbox and "
|
||||||
"click it within 24 hours to add your print to the queue.",
|
"click it within 24 hours to add your print to the queue.",
|
||||||
slug=submission.slug,
|
slug=submission.slug,
|
||||||
email=submission.guest_email,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user