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. +
+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 }}
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; + }Congrats! You've got a ${{ gift_card.amount }} Amazon gift card!
++ 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. +
+