diff --git a/server/vb/admin.py b/server/vb/admin.py index c0aaa00..7536041 100644 --- a/server/vb/admin.py +++ b/server/vb/admin.py @@ -123,11 +123,11 @@ def slug_display(self, obj: School): @admin.display(description="active contest") def active_contest(self, obj: School): """Return whether the school has an active contest.""" - current = obj.contests.current() - if current is None: + current_contest = obj.contests.current() + if current_contest is None: return "" - url = reverse("admin:vb_contest_change", args=[current.pk]) - return mark_safe(f'{current.name}') + url = reverse("admin:vb_contest_change", args=[current_contest.pk]) + return mark_safe(f'{current_contest.name}') @admin.display(description="Students") def student_count(self, obj: School): diff --git a/server/vb/migrations/0001_initial.py b/server/vb/migrations/0001_initial.py index f7b0c98..0504102 100644 --- a/server/vb/migrations/0001_initial.py +++ b/server/vb/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.3 on 2024-04-17 00:39 +# Generated by Django 5.0.3 on 2024-04-19 18:59 import django.core.validators import django.db.models.deletion @@ -44,8 +44,6 @@ class Migration(migrations.Migration): ('start_at', models.DateTimeField()), ('end_at', models.DateTimeField()), ('amount', models.IntegerField(default=5, help_text='The USD amount of the gift card.')), - ('name_template', models.TextField(default='${{ contest.amount }} Amazon Gift Card Giveaway', help_text='The name of the contest. Can use template variables like {{ school.name }} and {{ contest.amount }}.', max_length=255)), - ('description_template', models.TextField(default='{{ school.short_name }} students: check your voter registration to win a ${{ contest.amount }} Amazon gift card.', help_text='A description of the contest. Can use template variables like {{ school.name }} and {{ contest.amount }}.')), ('school', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contests', to='vb.school')), ], ), @@ -57,8 +55,8 @@ class Migration(migrations.Migration): ('updated_at', models.DateTimeField(auto_now=True)), ('email', models.EmailField(help_text='The first email address we ever saw for this user.', max_length=254, unique=True)), ('hash', models.CharField(help_text='A unique ID for the student derived from their email address.', max_length=64, unique=True)), - ('other_emails', models.JSONField(blank=True, default=list, help_text='The second (and beyond) emails for this user.')), - ('email_validated_at', models.DateTimeField(blank=True, default=None, help_text='The time the email was validated.', null=True)), + ('other_emails', models.JSONField(blank=True, default=list, help_text='The second+ emails for this user. These may not be validated.')), + ('email_validated_at', models.DateTimeField(blank=True, db_index=True, default=None, help_text='The time the email was validated.', null=True)), ('first_name', models.CharField(max_length=255)), ('last_name', models.CharField(max_length=255)), ('phone', models.CharField(blank=True, default='', max_length=255)), @@ -77,6 +75,19 @@ class Migration(migrations.Migration): ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gift_cards', to='vb.student')), ], ), + 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(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='email_validation_links', to='vb.contest')), + ('school', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_validation_links', to='vb.school')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_validation_links', to='vb.student')), + ], + ), migrations.AddConstraint( model_name='giftcard', constraint=models.UniqueConstraint(fields=('student', 'contest'), name='unique_student_contest_gift_card'), diff --git a/server/vb/migrations/0002_email_validation.py b/server/vb/migrations/0002_email_validation.py deleted file mode 100644 index bcc330d..0000000 --- a/server/vb/migrations/0002_email_validation.py +++ /dev/null @@ -1,40 +0,0 @@ -# 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 c1f320a..be4ac2e 100644 --- a/server/vb/models.py +++ b/server/vb/models.py @@ -50,6 +50,7 @@ class School(models.Model): logo: "Logo" contests: "ContestManager" students: "StudentManager" + email_validation_links: "EmailValidationLinkManager" def normalize_email(self, address: str) -> str: """Normalize an email address for this school.""" @@ -143,6 +144,12 @@ def past(self, when: datetime.datetime | None = None): when = when or django_now() return self.get_queryset().filter(end_at__lte=when) + def most_recent_past( + self, when: datetime.datetime | None = None + ) -> "Contest | None": + """Return the single most recent past contest, if any.""" + return self.past(when).order_by("-end_at").first() + def current(self, when: datetime.datetime | None = None) -> "Contest | None": """Return the single current contest.""" return self.ongoing(when).first() @@ -171,33 +178,21 @@ class Contest(models.Model): blank=False, help_text="The USD amount of the gift card.", default=5 ) - # The contest name and description can be templated. - name_template = models.TextField( - blank=False, - max_length=255, - help_text="The name of the contest. Can use template variables like {{ school.name }} and {{ contest.amount }}.", # noqa - default="${{ contest.amount }} Amazon Gift Card Giveaway", - ) - gift_cards: "GiftCardManager" @property def name(self) -> str: """Render the contest name template.""" + template_str = "${{ contest.amount }} Amazon Gift Card Giveaway" context = {"school": self.school, "contest": self} - return Template(self.name_template).render(Context(context)) - - description_template = models.TextField( - blank=False, - help_text="A description of the contest. Can use template variables like {{ school.name }} and {{ contest.amount }}.", # noqa - default="{{ school.short_name }} students: check your voter registration to win a ${{ contest.amount }} Amazon gift card.", # noqa - ) + return Template(template_str).render(Context(context)) @property def description(self) -> str: """Render the contest description template.""" + template_str = "{{ school.short_name }} students: check your voter registration to win a ${{ contest.amount }} Amazon gift card." # noqa context = {"school": self.school, "contest": self} - return Template(self.description_template).render(Context(context)) + return Template(template_str).render(Context(context)) def is_upcoming(self, when: datetime.datetime | None = None) -> bool: """Return whether the contest is upcoming.""" @@ -327,8 +322,18 @@ class EmailValidationLink(models.Model): student = models.ForeignKey( Student, on_delete=models.CASCADE, related_name="email_validation_links" ) + school = models.ForeignKey( + School, + on_delete=models.CASCADE, + related_name="email_validation_links", + ) contest = models.ForeignKey( - Contest, on_delete=models.CASCADE, related_name="email_validation_links" + Contest, + on_delete=models.CASCADE, + related_name="email_validation_links", + null=True, + # A user may still check registration outside of a contest. + # CONSTRAINT: contest.school must == student.school ) email = models.EmailField( @@ -353,15 +358,10 @@ class EmailValidationLink(models.Model): 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:validate_email", args=[self.contest.school.slug, self.token]) + return reverse("vb:validate_email", args=[self.school.slug, self.token]) @property def absolute_url(self) -> str: @@ -381,16 +381,6 @@ def consume(self, when: datetime.datetime | None = None) -> None: # 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 56a2e00..70722d7 100644 --- a/server/vb/ops.py +++ b/server/vb/ops.py @@ -123,12 +123,20 @@ def get_or_create_student( def send_validation_link_email( - student: Student, contest: Contest, email: str + student: Student, school: School, contest: Contest | None, email: str ) -> EmailValidationLink: """Generate a validation link to a student for a contest.""" link = EmailValidationLink.objects.create( - student=student, contest=contest, email=email, token=make_token(12) + student=student, + school=school, + contest=contest, + email=email, + token=make_token(12), ) + if contest: + button_text = f"Get my ${contest.amount} gift card" + else: + button_text = "Validate my email" success = send_template_email( to=email, template_base="email/validate", @@ -137,7 +145,7 @@ def send_validation_link_email( "contest": contest, "email": email, "link": link, - "button_text": f"Get my ${contest.amount} gift card", + "button_text": button_text, }, ) if not success: diff --git a/server/vb/templates/check.dhtml b/server/vb/templates/check.dhtml index 79124e6..7224805 100644 --- a/server/vb/templates/check.dhtml +++ b/server/vb/templates/check.dhtml @@ -143,6 +143,8 @@ alt="{{ school.short_name }} {{ school.mascot }} logo" /> {% if current_contest %} {% include "components/countdown.dhtml" with contest=current_contest %} + {% else %} +
Check your voter registration status below.
{% endif %} diff --git a/server/vb/templates/email/validate/body.dhtml b/server/vb/templates/email/validate/body.dhtml index 71ddf11..aad504f 100644 --- a/server/vb/templates/email/validate/body.dhtml +++ b/server/vb/templates/email/validate/body.dhtml @@ -5,7 +5,7 @@ Hi {{ student.first_name }}, {% include "email/base/p_close.html" %} {% include "email/base/p.html" %} - Thanks for checking your voter registration status! Click this button to get your gift card. + Thanks for checking your voter registration status! Click this button to {% if contest %}get your gift card{% else %}validate your email address{% endif %}. {% include "email/base/p_close.html" %} {% include "email/base/button.html" with url=link.relative_url title=button_text %} {% endblock message %} diff --git a/server/vb/templates/email/validate/body.txt b/server/vb/templates/email/validate/body.txt index 6681a98..6f0341b 100644 --- a/server/vb/templates/email/validate/body.txt +++ b/server/vb/templates/email/validate/body.txt @@ -4,7 +4,7 @@ Thanks for checking your voter registration status! You're almost done. -Click this link to get your ${{ contest.amount }} Amazon gift card: +{% if contest %}Click this link to get your ${{ contest.amount }} Amazon gift card:{% else %}Click this link to validate your email address:{% endif %} {{ link.absolute_url }} {% endblock message %} diff --git a/server/vb/templates/email/validate/subject.txt b/server/vb/templates/email/validate/subject.txt index e1227b5..9b2f00a 100644 --- a/server/vb/templates/email/validate/subject.txt +++ b/server/vb/templates/email/validate/subject.txt @@ -1 +1 @@ -Your VoterBowl gift card awaits +{% if contest %}Your VoterBowl gift card awaits{% else %}Verify your email address{% endif %} diff --git a/server/vb/templates/finish_check.dhtml b/server/vb/templates/finish_check.dhtml index 2e4eb5c..9f89069 100644 --- a/server/vb/templates/finish_check.dhtml +++ b/server/vb/templates/finish_check.dhtml @@ -8,5 +8,10 @@ setTimeout(() => fireworks.stop(), 10_000); })(me()); - Please check your email. We've sent you a link to claim your ${{ current_contest.amount }} gift card. + Please check your email. + {% if current_contest %} + We've sent you a link to claim your ${{ current_contest.amount }} gift card. + {% else %} + We've sent you a validation link to confirm your address. + {% endif %} diff --git a/server/vb/templates/school.dhtml b/server/vb/templates/school.dhtml index 03e1b09..78ef910 100644 --- a/server/vb/templates/school.dhtml +++ b/server/vb/templates/school.dhtml @@ -82,8 +82,14 @@{{ current_contest.description }}
+ {% elif past_contest %} ++ {{ school.short_name }} students: the ${{ past_contest.amount }} giveaway ended {{ past_contest.ended_at | timesince }}, but it's always a good time to make sure you're ready to vote. +
{% else %} -There is no contest at this time. TODO: show something useful in this state?
++ {{ school.short_name }} students: there's no giveaway right now, but it's always a good time to make sure you're ready to vote. +
{% endif %}