From 8e4511a59c1ee09fadf8b6d762856d7fcfff4eda Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 18 Apr 2024 14:29:18 -0700 Subject: [PATCH] Good path verification flow done and done. --- server/vb/admin.py | 195 +++++++++++++++++- server/vb/migrations/0002_email_validation.py | 40 ++++ server/vb/models.py | 107 +++++++++- server/vb/ops.py | 63 ++++-- server/vb/templates/check.dhtml | 3 +- server/vb/templates/clipboard.svg | 3 + server/vb/templates/clipboard_check.svg | 3 + .../templates/components/logo_specimen.dhtml | 97 ++++----- server/vb/templates/finish_check.dhtml | 4 +- server/vb/templates/school.dhtml | 4 + server/vb/templates/verify_email.dhtml | 145 +++++++++++++ server/vb/urls.py | 4 +- server/vb/views.py | 80 ++++++- 13 files changed, 668 insertions(+), 80 deletions(-) create mode 100644 server/vb/migrations/0002_email_validation.py create mode 100644 server/vb/templates/clipboard.svg create mode 100644 server/vb/templates/clipboard_check.svg create mode 100644 server/vb/templates/verify_email.dhtml diff --git a/server/vb/admin.py b/server/vb/admin.py index 8a5a93a..c0aaa00 100644 --- a/server/vb/admin.py +++ b/server/vb/admin.py @@ -3,12 +3,23 @@ from django import forms from django.contrib import admin from django.core.files.uploadedfile import UploadedFile +from django.db import models from django.template.loader import render_to_string +from django.urls import reverse from django.utils.safestring import mark_safe +from django.utils.timezone import now as django_now from server.admin import admin_site -from .models import Contest, GiftCard, ImageMimeType, Logo, School, Student +from .models import ( + Contest, + EmailValidationLink, + GiftCard, + ImageMimeType, + Logo, + School, + Student, +) def validate_file_is_image(file: UploadedFile) -> None: @@ -88,6 +99,7 @@ class SchoolAdmin(admin.ModelAdmin, RenderLogoSpecimenMixin): "slug_display", "logo_display", "active_contest", + "student_count", "mascot", "mail_domains", ) @@ -112,14 +124,96 @@ def slug_display(self, obj: School): def active_contest(self, obj: School): """Return whether the school has an active contest.""" current = obj.contests.current() - return "" if current is None else current.name + if current is None: + return "" + url = reverse("admin:vb_contest_change", args=[current.pk]) + return mark_safe(f'{current.name}') + + @admin.display(description="Students") + def student_count(self, obj: School): + """Return the number of students at the school.""" + count = obj.students.count() + return count if count > 0 else "" + + +class EmailValidatedListFilter(admin.SimpleListFilter): + """Email validated list filter.""" + + title = "Email Validated" + parameter_name = "email_validated" + + def lookups(self, request, model_admin): + """Return the email validated lookups.""" + return ( + ("yes", "Yes"), + ("no", "No"), + ) + + def queryset(self, request, queryset): + """Filter the queryset by email validated.""" + if self.value() == "yes": + return queryset.filter(email_validated_at__isnull=False) + if self.value() == "no": + return queryset.filter(email_validated_at__isnull=True) + return queryset class StudentAdmin(admin.ModelAdmin): """Student admin.""" - list_display = ("school", "email", "first_name", "last_name") + list_display = ( + "name", + "show_school", + "email", + "show_is_validated", + "gift_card_total", + ) search_fields = ("school__name", "email", "first_name", "last_name") + list_filter = (EmailValidatedListFilter, "school__name") + + @admin.display(description="School") + def show_school(self, obj: Student) -> str: + """Return the student's school.""" + # Get the link to the school admin page. + school_admin_link = reverse("admin:vb_school_change", args=[obj.school.pk]) + return mark_safe(f'{obj.school.name}') + + @admin.display(description="Email Validated", boolean=True) + def show_is_validated(self, obj: Student) -> bool: + """Return whether the student's email is validated.""" + return obj.is_validated + + @admin.display(description="Gift Card Total") + def gift_card_total(self, obj: Student) -> str | None: + """Return the total number of gift cards the student has received.""" + usd = obj.gift_cards.aggregate(total=models.Sum("amount"))["total"] or 0 + return f"${usd}" if usd > 0 else None + + +class StatusListFilter(admin.SimpleListFilter): + """Status list filter.""" + + title = "Status" + parameter_name = "status" + + def lookups(self, request, model_admin): + """Return the status lookups.""" + return ( + ("ongoing", "Ongoing"), + ("upcoming", "Upcoming"), + ("past", "Past"), + ) + + def queryset(self, request, queryset): + """Filter the queryset by status.""" + when = django_now() + if self.value() == "ongoing": + return queryset.filter(start_at__lte=when, end_at__gt=when) + if self.value() == "upcoming": + return queryset.filter(start_at__gt=when) + if self.value() == "past": + return queryset.filter(end_at__lte=when) + return queryset class ContestAdmin(admin.ModelAdmin): @@ -128,21 +222,110 @@ class ContestAdmin(admin.ModelAdmin): list_display = ( "id", "name", - "school", + "status", + "show_school", "start_at", "end_at", ) search_fields = ("school__name", "school__short_name", "school__slug") + list_filter = (StatusListFilter, "school__name") + + @admin.display(description="Status") + def status(self, obj: Contest) -> str: + """Return the contest's status.""" + if obj.is_ongoing(): + return "Ongoing" + elif obj.is_upcoming(): + return "Upcoming" + elif obj.is_past(): + return "Past" + raise ValueError("Invalid contest status") + + @admin.display(description="School") + def show_school(self, obj: Contest) -> str: + """Return the student's school.""" + # Get the link to the school admin page. + school_admin_link = reverse("admin:vb_school_change", args=[obj.school.pk]) + return mark_safe(f'{obj.school.name}') class GiftCardAdmin(admin.ModelAdmin): """Gift card admin.""" - list_display = ("id", "created_at") - search_fields = ("id", "created_at") + list_display = ( + "id", + "created_at", + "show_amount", + "show_student", + "show_school", + "show_contest", + ) + search_fields = ("id", "created_at", "student__email", "student__name") + + @admin.display(description="Amount") + def show_amount(self, obj: GiftCard) -> str: + """Return the gift card's amount.""" + return f"${obj.amount}" + + @admin.display(description="Student") + def show_student(self, obj: GiftCard) -> str: + """Return the gift card's student.""" + url = reverse("admin:vb_student_change", args=[obj.student.pk]) + return mark_safe(f'{obj.student.name}') + + @admin.display(description="School") + def show_school(self, obj: GiftCard) -> str: + """Return the gift card's school.""" + url = reverse("admin:vb_school_change", args=[obj.student.school.pk]) + return mark_safe(f'{obj.student.school.name}') + + @admin.display(description="Contest") + def show_contest(self, obj: GiftCard) -> str: + """Return the gift card's contest.""" + url = reverse("admin:vb_contest_change", args=[obj.contest.pk]) + return mark_safe(f'{obj.contest.name}') + + +class EmailValidationLinkAdmin(admin.ModelAdmin): + """Email validation link admin.""" + + list_display = ( + "id", + "email", + "show_student", + "show_school", + "show_contest", + "token", + "is_consumed", + ) + search_fields = ("email", "token") + + @admin.display(description="Student") + def show_student(self, obj: GiftCard) -> str: + """Return the gift card's student.""" + url = reverse("admin:vb_student_change", args=[obj.student.pk]) + return mark_safe(f'{obj.student.name}') + + @admin.display(description="School") + def show_school(self, obj: GiftCard) -> str: + """Return the gift card's school.""" + url = reverse("admin:vb_school_change", args=[obj.student.school.pk]) + return mark_safe(f'{obj.student.school.name}') + + @admin.display(description="Contest") + def show_contest(self, obj: GiftCard) -> str: + """Return the gift card's contest.""" + url = reverse("admin:vb_contest_change", args=[obj.contest.pk]) + return mark_safe(f'{obj.contest.name}') + + @admin.display(description="Is Consumed", boolean=True) + def is_consumed(self, obj: EmailValidationLink) -> bool: + """Return whether the email validation link is consumed.""" + return obj.is_consumed() admin_site.register(School, SchoolAdmin) admin_site.register(Student, StudentAdmin) admin_site.register(Contest, ContestAdmin) admin_site.register(GiftCard, GiftCardAdmin) +admin_site.register(EmailValidationLink, EmailValidationLinkAdmin) diff --git a/server/vb/migrations/0002_email_validation.py b/server/vb/migrations/0002_email_validation.py new file mode 100644 index 0000000..bcc330d --- /dev/null +++ b/server/vb/migrations/0002_email_validation.py @@ -0,0 +1,40 @@ +# Generated by Django 5.0.3 on 2024-04-18 19:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vb', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='student', + name='email_validated_at', + field=models.DateTimeField(blank=True, db_index=True, default=None, help_text='The time the email was validated.', null=True), + ), + migrations.AlterField( + model_name='student', + name='other_emails', + field=models.JSONField(blank=True, default=list, help_text='The second+ emails for this user. These may not be validated.'), + ), + migrations.CreateModel( + name='EmailValidationLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(help_text='The specific email address to be validated.', max_length=254)), + ('token', models.CharField(help_text='The current validation token, if any.', max_length=255, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='The time the email validation link was most recently created.')), + ('consumed_at', models.DateTimeField(blank=True, default=None, help_text='The time the email validation link was first consumed.', null=True)), + ('contest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_validation_links', to='vb.contest')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_validation_links', to='vb.student')), + ], + ), + migrations.AddConstraint( + model_name='emailvalidationlink', + constraint=models.UniqueConstraint(fields=('student', 'contest'), name='unique_student_contest_email_validation_link'), + ), + ] diff --git a/server/vb/models.py b/server/vb/models.py index 14b96c7..fe341f0 100644 --- a/server/vb/models.py +++ b/server/vb/models.py @@ -3,9 +3,11 @@ import hashlib import typing as t +from django.conf import settings from django.core.exceptions import ValidationError from django.db import models from django.template import Context, Template +from django.urls import reverse from django.utils.timezone import now as django_now from server.utils.contrast import HEX_COLOR_VALIDATOR, get_text_color @@ -260,12 +262,13 @@ class Student(models.Model): other_emails = models.JSONField( default=list, blank=True, - help_text="The second (and beyond) emails for this user.", + help_text="The second+ emails for this user. These may not be validated.", ) email_validated_at = models.DateTimeField( blank=True, null=True, default=None, + db_index=True, help_text="The time the email was validated.", ) @@ -275,17 +278,119 @@ class Student(models.Model): phone = models.CharField(max_length=255, blank=True, default="") gift_cards: "GiftCardManager" + email_validation_links: "EmailValidationLinkManager" @property def is_validated(self) -> bool: """Return whether the student's email address is validated.""" return self.email_validated_at is not None + def mark_validated(self, when: datetime.datetime | None = None) -> None: + """Mark the student's email address as validated.""" + self.email_validated_at = self.email_validated_at or when or django_now() + self.save() + @property def name(self) -> str: """Return the student's full name.""" return f"{self.first_name} {self.last_name}" + def add_email(self, email: str) -> None: + """Add an email address to the student's list of emails.""" + if email != self.email and email not in self.other_emails: + self.other_emails.append(email) + self.save() + + +class EmailValidationLinkManager(models.Manager): + """A custom manager for the email validation link model.""" + + OLD_DELTA = datetime.timedelta(days=7) + + def consumed(self): + """Return all email validation links that are consumed.""" + return self.filter(consumed_at__isnull=False) + + def not_consumed(self): + """Return all email validation links that are not consumed.""" + return self.filter(consumed_at__isnull=True) + + def old(self, when: datetime.datetime | None = None): + """Return all email validation links that are old.""" + when = when or django_now() + return self.filter(created_at__lt=when - self.OLD_DELTA) + + +class EmailValidationLink(models.Model): + """A single email validation link for a student in a contest.""" + + student = models.ForeignKey( + Student, on_delete=models.CASCADE, related_name="email_validation_links" + ) + contest = models.ForeignKey( + Contest, on_delete=models.CASCADE, related_name="email_validation_links" + ) + + email = models.EmailField( + blank=False, + help_text="The specific email address to be validated.", + ) + + token = models.CharField( + blank=False, + max_length=255, + unique=True, + help_text="The current validation token, if any.", + ) + created_at = models.DateTimeField( + auto_now_add=True, + help_text="The time the email validation link was most recently created.", + ) + consumed_at = models.DateTimeField( + blank=True, + null=True, + default=None, + help_text="The time the email validation link was first consumed.", + ) + + @property + def school(self) -> School: + """Return the school associated with the email validation link.""" + return self.student.school + + @property + def relative_url(self) -> str: + """Return the relative URL for the email validation link.""" + return reverse("vb:verify_email", args=[self.contest.school.slug, self.token]) + + @property + def absolute_url(self) -> str: + """Return the absolute URL for the email validation link.""" + return f"{settings.BASE_URL}{self.relative_url}" + + def is_consumed(self) -> bool: + """Return whether the email validation link has been consumed.""" + return self.consumed_at is not None + + def consume(self, when: datetime.datetime | None = None) -> None: + """Consume the email validation link.""" + when = when or django_now() + self.consumed_at = when + self.save() + + # Demeter says no, but my heart says yes. + self.student.mark_validated(when) + + class Meta: + """Define the email validation link model's meta options.""" + + constraints = [ + models.UniqueConstraint( + fields=["student", "contest"], + name="unique_student_contest_email_validation_link", + ) + ] + class GiftCardManager(models.Manager): """A custom manager for the gift card model.""" diff --git a/server/vb/ops.py b/server/vb/ops.py index cdeb9c9..b28bfef 100644 --- a/server/vb/ops.py +++ b/server/vb/ops.py @@ -4,8 +4,9 @@ from django.db import transaction from server.utils.agcod import AGCODClient +from server.utils.tokens import make_token -from .models import Contest, GiftCard, Student +from .models import Contest, EmailValidationLink, GiftCard, School, Student logger = logging.getLogger(__name__) @@ -42,9 +43,24 @@ def _issue_gift_card(student: Student, contest: Contest) -> tuple[GiftCard, str] return gift_card, response.gc_claim_code +def _get_claim_code(gift_card: GiftCard) -> str: + """Return the claim code for a gift card if it is not currently known.""" + client = AGCODClient.from_settings() + try: + response = client.check_gift_card( + gift_card.amount, gift_card.creation_request_id + ) + except Exception as e: + logger.exception(f"AGCOD failed for gift card {gift_card.creation_request_id}") + raise ValueError( + f"AGCOD failed for gift card {gift_card.creation_request_id}" + ) from e + return response.gc_claim_code + + def get_or_issue_gift_card( student: Student, contest: Contest, when: datetime.datetime | None = None -) -> tuple[GiftCard, str | None]: +) -> tuple[GiftCard, str]: """ Issue a gift card to a student for a contest. @@ -77,7 +93,8 @@ def get_or_issue_gift_card( gift_card = None if gift_card is not None: - return gift_card, None + claim_code = _get_claim_code(gift_card) + return gift_card, claim_code # Precondition: the contest must be ongoing to truly issue a gift card. if not contest.is_ongoing(when): @@ -86,16 +103,30 @@ def get_or_issue_gift_card( return _issue_gift_card(student, contest) -def get_claim_code(gift_card: GiftCard) -> str: - """Return the claim code for a gift card if it is not currently known.""" - client = AGCODClient.from_settings() - try: - response = client.check_gift_card( - gift_card.amount, gift_card.creation_request_id - ) - except Exception as e: - logger.exception(f"AGCOD failed for gift card {gift_card.creation_request_id}") - raise ValueError( - f"AGCOD failed for gift card {gift_card.creation_request_id}" - ) from e - return response.gc_claim_code +def get_or_create_student( + school: School, hash: str, email: str, first_name: str, last_name: str +) -> Student: + """Get or create a student by hash.""" + student, _ = Student.objects.get_or_create( + hash=hash, + school=school, + defaults={ + "first_name": first_name, + "last_name": last_name, + "email": email, + }, + ) + student.add_email(email) + return student + + +def send_validation_link_email( + student: Student, contest: Contest, email: str +) -> EmailValidationLink: + """Generate a validation link to a student for a contest.""" + # TODO dave + link = EmailValidationLink.objects.create( + student=student, contest=contest, email=email, token=make_token(12) + ) + print("TODO SENDING A VALIDATION LINK: ", link.absolute_url) + return link diff --git a/server/vb/templates/check.dhtml b/server/vb/templates/check.dhtml index e53a58c..a20c9be 100644 --- a/server/vb/templates/check.dhtml +++ b/server/vb/templates/check.dhtml @@ -133,6 +133,7 @@ Voter Bowl x {{ school.short_name }} const fireworks = new Fireworks.default(self.querySelector('.fireworks')); fireworks.start(); finishVerify(data.first_name, data.last_name, data.email); + setTimeout(() => fireworks.stop(), 10_000); }, 500); } }); @@ -151,7 +152,7 @@ Voter Bowl x {{ school.short_name }}
-
+
diff --git a/server/vb/templates/clipboard.svg b/server/vb/templates/clipboard.svg new file mode 100644 index 0000000..3bea6d8 --- /dev/null +++ b/server/vb/templates/clipboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/server/vb/templates/clipboard_check.svg b/server/vb/templates/clipboard_check.svg new file mode 100644 index 0000000..66c1bc3 --- /dev/null +++ b/server/vb/templates/clipboard_check.svg @@ -0,0 +1,3 @@ + + + diff --git a/server/vb/templates/components/logo_specimen.dhtml b/server/vb/templates/components/logo_specimen.dhtml index 22f1881..f41e154 100644 --- a/server/vb/templates/components/logo_specimen.dhtml +++ b/server/vb/templates/components/logo_specimen.dhtml @@ -1,57 +1,60 @@ {% with width=width|default:"32px" height=height|default:"32px" %} -
- -
- TODO alt -
-
+
+
text
-
action
-
-{% endwith %} \ No newline at end of file + +{% endwith %} diff --git a/server/vb/templates/finish_check.dhtml b/server/vb/templates/finish_check.dhtml index 26dee5f..816386c 100644 --- a/server/vb/templates/finish_check.dhtml +++ b/server/vb/templates/finish_check.dhtml @@ -1,3 +1,5 @@ {{ school.short_name }} {{ school.mascot }} logo -

CONGRATS! Check your email {{ first_name }} AND {{ last_name }}, because some dope shizz is on its way.

+

+ Please check your email. We've sent you a link to claim your ${{ current_contest.amount }} gift card. +

diff --git a/server/vb/templates/school.dhtml b/server/vb/templates/school.dhtml index 4671a5a..03e1b09 100644 --- a/server/vb/templates/school.dhtml +++ b/server/vb/templates/school.dhtml @@ -49,6 +49,10 @@ justify-content: center; margin: 1.5rem 0; } + + me .faq { + background-color: black; + }
diff --git a/server/vb/templates/verify_email.dhtml b/server/vb/templates/verify_email.dhtml new file mode 100644 index 0000000..9b30d8f --- /dev/null +++ b/server/vb/templates/verify_email.dhtml @@ -0,0 +1,145 @@ +{% extends "base.dhtml" %} +{% load static %} + +{% block title %} + Voter Bowl x {{ school.short_name }} +{% endblock title %} + +{% block body %} +
+ +
+ + +
+ {{ school.short_name }} {{ school.mascot }} logo +

Congrats! You've got a ${{ gift_card.amount }} Amazon gift card!

+

+ {{ claim_code }} + {% include "clipboard.svg" %} + +

+

+ To use your gift card, copy the code above and paste it into Amazon.com's redemption page. That's all there is to it. +

+
+
+
+
{% include "includes/faq.dhtml" %}
+
+
+{% endblock body %} diff --git a/server/vb/urls.py b/server/vb/urls.py index c5e2781..b062425 100644 --- a/server/vb/urls.py +++ b/server/vb/urls.py @@ -1,8 +1,10 @@ from django.urls import path -from .views import check, finish_check, home, school +from .views import check, finish_check, home, school, verify_email +app_name = "vb" urlpatterns = [ + path("/verify//", verify_email, name="verify_email"), path("/check/finish/", finish_check, name="finish_check"), path("/check/", check, name="check"), path("/", school, name="school"), diff --git a/server/vb/views.py b/server/vb/views.py index 7a008e1..ed15f9b 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -1,11 +1,17 @@ from django import forms +from django.conf import settings from django.core.exceptions import PermissionDenied from django.http import HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, render from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_GET, require_POST -from .models import School +from .models import EmailValidationLink, School +from .ops import ( + get_or_create_student, + get_or_issue_gift_card, + send_validation_link_email, +) @require_GET @@ -34,13 +40,36 @@ def check(request: HttpRequest, slug: str) -> HttpResponse: ) -class VerifyForm(forms.Form): - """Form for verifying a check.""" +class FinishCheckForm(forms.Form): + """Data POSTed to finish_check when user has completed a registration check.""" + + _school: School + + def __init__(self, data, school: School): + """Construct a FinishCheckForm.""" + super().__init__(data) + self._school = school first_name = forms.CharField(max_length=100) last_name = forms.CharField(max_length=100) email = forms.EmailField() + def clean_email(self): + """Ensure the email address is not already in use.""" + email = self.cleaned_data["email"] + + # DEBUG-mode email address bypass. + if ( + settings.DEBUG + and email.endswith("@example.edu") + or email.endswith(".example.edu") + ): + self.cleaned_data["hash"] = "dbg-" + email + return email + self._school.validate_email(email) + self.cleaned_data["hash"] = self._school.hash_email(email) + return email + @require_POST @csrf_exempt # CONSIDER: maybe use Django's CSRF protection even here? @@ -50,9 +79,24 @@ def finish_check(request: HttpRequest, slug: str) -> HttpResponse: current_contest = school.contests.current() if not current_contest: raise ValueError("No active contest TODO") - form = VerifyForm(request.POST) + form = FinishCheckForm(request.POST, school=school) if not form.is_valid(): raise PermissionDenied("Invalid form") + email = form.cleaned_data["email"] + + # Create a new student if necessary. + student = get_or_create_student( + school=school, + hash=form.cleaned_data["hash"], + email=email, + first_name=form.cleaned_data["first_name"], + last_name=form.cleaned_data["last_name"], + ) + + # Always send a validation link EVEN if the student is validated. + # This ensures we never show a gift code until we know the visitor + # has access to the email address. + send_validation_link_email(student, current_contest, email) return render( request, @@ -60,8 +104,30 @@ def finish_check(request: HttpRequest, slug: str) -> HttpResponse: { "school": school, "current_contest": current_contest, - "first_name": form.cleaned_data["first_name"], - "last_name": form.cleaned_data["last_name"], - "email": form.cleaned_data["email"], + "email": email, + }, + ) + + +@require_GET +def verify_email(request: HttpRequest, slug: str, token: str) -> HttpResponse: + """Handle a student email validation link.""" + link = get_object_or_404(EmailValidationLink, token=token) + school = get_object_or_404(School, slug=slug) + if link.school != school: + raise PermissionDenied("Invalid email validation link URL") + + # It's money time! + link.consume() + gift_card, claim_code = get_or_issue_gift_card(link.student, link.contest) + + return render( + request, + "verify_email.dhtml", + { + "school": school, + "student": link.student, + "gift_card": gift_card, + "claim_code": claim_code, }, )