Skip to content

Commit

Permalink
Merge pull request #32 from front-seat/staging
Browse files Browse the repository at this point in the history
Finalize changes to support 1-in-N competitions
  • Loading branch information
davepeck authored Apr 26, 2024
2 parents dd6e28d + 93b898e commit 0a2b176
Show file tree
Hide file tree
Showing 25 changed files with 775 additions and 250 deletions.
19 changes: 7 additions & 12 deletions server/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

# SECURITY WARNING: keep the secret key used in production secret!
BASE_URL = os.environ["BASE_URL"] # Of the form "https://www.voterbowl.org"
BASE_HOST = BASE_URL.split("://")[1]
if not DEBUG and not BASE_URL.startswith("https://"):
raise ValueError("BASE_URL must be HTTPS in production")
if BASE_URL.endswith("/"):
Expand All @@ -50,6 +51,7 @@
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.humanize",
"django_browser_reload",
"django_htmx",
"server.vb",
Expand Down Expand Up @@ -196,19 +198,12 @@
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "false").lower() == "true"
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true"

# Special debug tool: emails matching this pattern will be sent to the
# debug email address instead of the actual recipient. This is useful for
# testing email functionality without spamming real users.
#
# For instance, if DEBUG_EMAIL_STARTSWITH is set to "frontseat@",
# then any email sent to "[email protected]" will instead be sent to
# DEBUG_EMAIL_TO.
DEBUG_EMAIL_STARTSWITH = os.getenv("DEBUG_EMAIL_STARTSWITH", None)
# Special debug tool: emails matching the pattern
# frontseat-<digits>@ will be sent to DEBUG_EMAIL_TO
# instead of the actual recipient. This is useful for testing email
# functionality without spamming real users.
DEBUG_EMAIL_REGEX = r"^frontseat-[a-zA-Z0-9]+@"
DEBUG_EMAIL_TO = os.getenv("DEBUG_EMAIL_TO", None)
if not DEBUG_EMAIL_STARTSWITH or not DEBUG_EMAIL_TO:
DEBUG_EMAIL_STARTSWITH = None
DEBUG_EMAIL_TO = None


# ----------------------------------------------------------------------------
# Logging settings
Expand Down
8 changes: 5 additions & 3 deletions server/utils/email.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import dataclasses
import logging
import re
import typing as t

from django.conf import settings
Expand Down Expand Up @@ -103,18 +104,19 @@ def create_message(
context = context or {}
from_email = from_email or settings.DEFAULT_FROM_EMAIL
context.setdefault("BASE_URL", settings.BASE_URL)
context.setdefault("BASE_HOST", settings.BASE_HOST)

subject = render_to_string(f"{template_base}/subject.txt", context).strip()
text = render_to_string(f"{template_base}/body.txt", context)
html = render_to_string(f"{template_base}/body.dhtml", context)

final_to = list(to)
if settings.DEBUG_EMAIL_STARTSWITH and settings.DEBUG_EMAIL_TO:
if settings.DEBUG_EMAIL_TO:
for i, email in enumerate(final_to):
if email.startswith(settings.DEBUG_EMAIL_STARTSWITH):
if re.match(settings.DEBUG_EMAIL_REGEX, email):
final_to[i] = settings.DEBUG_EMAIL_TO
logger.info(
f"DEBUG_EMAIL_STARTSWITH rerouting email {email} to {settings.DEBUG_EMAIL_TO} with subject: {subject}" # noqa
f"DEBUG_EMAIL rerouting email {email} to {settings.DEBUG_EMAIL_TO} with subject: {subject}" # noqa
)

message = EmailMultiAlternatives(
Expand Down
134 changes: 105 additions & 29 deletions server/vb/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@

from .models import (
Contest,
ContestEntry,
EmailValidationLink,
GiftCard,
ImageMimeType,
Logo,
School,
Expand Down Expand Up @@ -166,6 +166,7 @@ class StudentAdmin(admin.ModelAdmin):
"show_school",
"email",
"show_is_validated",
"contest_entries",
"gift_card_total",
)
search_fields = ("school__name", "email", "first_name", "last_name")
Expand All @@ -183,11 +184,19 @@ def show_is_validated(self, obj: Student) -> bool:
"""Return whether the student's email is validated."""
return obj.is_validated

@admin.display(description="Contest Entries")
def contest_entries(self, obj: Student) -> int | str:
"""Return the number of contest entries the student has made."""
count = obj.contest_entries.count()
return count if count > 0 else ""

@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
usd = (
obj.contest_entries.aggregate(total=models.Sum("amount_won"))["total"] or 0
)
return f"${usd}" if usd > 0 else ""


class StatusListFilter(admin.SimpleListFilter):
Expand Down Expand Up @@ -249,39 +258,106 @@ def show_school(self, obj: Contest) -> str:
return mark_safe(f'<a href="{school_admin_link}">{obj.school.name}</a>')


class GiftCardAdmin(admin.ModelAdmin):
"""Gift card admin."""
class ContestWinnerListFilter(admin.SimpleListFilter):
"""Contest winner list filter."""

title = "Winner?"
parameter_name = "winner"

def lookups(self, request, model_admin):
"""Return the state lookups."""
return (
("winner", "Winner"),
("loser", "Loser"),
)

def queryset(self, request, queryset):
"""Filter the queryset by state."""
if self.value() == "winner":
return queryset.filter(roll=0)
if self.value() == "loser":
return queryset.exclude(roll=0)
return queryset


class ContestWinningsIssuedListFilter(admin.SimpleListFilter):
"""Contest winnings issued list filter."""

title = "Winnings Issued?"
parameter_name = "winnings_issued"

def lookups(self, request, model_admin):
"""Return the state lookups."""
return (
("yes", "Yes"),
("no", "No"),
)

def queryset(self, request, queryset):
"""Filter the queryset by state."""
if self.value() == "yes":
return queryset.filter(creation_request_id__ne="")
if self.value() == "no":
return queryset.filter(creation_request_id__eq="")
return queryset


class ContestEntryAdmin(admin.ModelAdmin):
"""Contest Entry admin."""

list_display = (
"id",
"created_at",
"show_amount",
"show_is_winner",
"show_winnings",
"show_winnings_issued",
"show_student",
"show_school",
"show_contest",
"roll",
)
search_fields = ("id", "created_at", "student__email")
list_filter = (
ContestWinnerListFilter,
ContestWinningsIssuedListFilter,
"contest__school__name",
)
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="Winner?", boolean=True)
def show_is_winner(self, obj: ContestEntry) -> bool:
"""Return whether the contest entry is a winner."""
return obj.is_winner

@admin.display(description="Winnings")
def show_winnings(self, obj: ContestEntry) -> str:
"""Return the contest entry's winnings, if any, if any."""
return f"${obj.amount_won}" if obj.is_winner else ""

@admin.display(description="Issued?")
def show_winnings_issued(self, obj: ContestEntry) -> str:
"""Return whether the contest entry's winnings have been issued."""
if obj.has_issued:
return "Yes"
elif obj.is_winner:
return "Not yet"
else:
return ""

@admin.display(description="Student")
def show_student(self, obj: GiftCard) -> str:
"""Return the gift card's student."""
def show_student(self, obj: ContestEntry) -> str:
"""Return the contest entry's student."""
url = reverse("admin:vb_student_change", args=[obj.student.pk])
return mark_safe(f'<a href="{url}">{obj.student.name}</a>')

@admin.display(description="School")
def show_school(self, obj: GiftCard) -> str:
"""Return the gift card's school."""
def show_school(self, obj: ContestEntry) -> str:
"""Return the contest entry's school."""
url = reverse("admin:vb_school_change", args=[obj.student.school.pk])
return mark_safe(f'<a href="{url}">{obj.student.school.name}</a>')

@admin.display(description="Contest")
def show_contest(self, obj: GiftCard) -> str:
"""Return the gift card's contest."""
def show_contest(self, obj: ContestEntry) -> str:
"""Return the contest entry's contest."""
url = reverse("admin:vb_contest_change", args=[obj.contest.pk])
return mark_safe(f'<a href="{url}">{obj.contest.name}</a>')

Expand All @@ -294,35 +370,35 @@ class EmailValidationLinkAdmin(admin.ModelAdmin):
"email",
"show_student",
"show_school",
"show_contest",
"show_contest_entry",
"token",
"is_consumed",
)
search_fields = ("email", "token")

@admin.display(description="Student")
def show_student(self, obj: GiftCard) -> str:
"""Return the gift card's student."""
def show_student(self, obj: EmailValidationLink) -> str:
"""Return the validation link's student."""
if obj.student is None:
return ""
url = reverse("admin:vb_student_change", args=[obj.student.pk])
return mark_safe(f'<a href="{url}">{obj.student.name}</a>')

@admin.display(description="School")
def show_school(self, obj: GiftCard) -> str:
"""Return the gift card's school."""
def show_school(self, obj: EmailValidationLink) -> str:
"""Return the validation link's school."""
if obj.student is None or obj.student.school is None:
return ""
url = reverse("admin:vb_school_change", args=[obj.student.school.pk])
return mark_safe(f'<a href="{url}">{obj.student.school.name}</a>')

@admin.display(description="Contest")
def show_contest(self, obj: GiftCard) -> str:
"""Return the gift card's contest."""
if obj.contest is None:
@admin.display(description="Contest Entry")
def show_contest_entry(self, obj: EmailValidationLink) -> str:
"""Return the gift card's contest entry."""
if obj.contest_entry is None:
return ""
url = reverse("admin:vb_contest_change", args=[obj.contest.pk])
return mark_safe(f'<a href="{url}">{obj.contest.name}</a>')
url = reverse("admin:vb_contestentry_change", args=[obj.contest_entry.pk])
return mark_safe(f'<a href="{url}">{str(obj.contest_entry)}</a>')

@admin.display(description="Is Consumed", boolean=True)
def is_consumed(self, obj: EmailValidationLink) -> bool:
Expand All @@ -333,5 +409,5 @@ def is_consumed(self, obj: EmailValidationLink) -> bool:
admin_site.register(School, SchoolAdmin)
admin_site.register(Student, StudentAdmin)
admin_site.register(Contest, ContestAdmin)
admin_site.register(GiftCard, GiftCardAdmin)
admin_site.register(ContestEntry, ContestEntryAdmin)
admin_site.register(EmailValidationLink, EmailValidationLinkAdmin)
22 changes: 22 additions & 0 deletions server/vb/migrations/0002_rename_gift_card.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 5.0.3 on 2024-04-24 23:56

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('vb', '0001_initial'),
]

operations = [
migrations.RenameModel(
old_name='GiftCard',
new_name='ContestEntry',
),
migrations.AddField(
model_name='contest',
name='in_n',
field=models.IntegerField(default=1, help_text='1 in_n students will win a gift card.'),
),
]
23 changes: 23 additions & 0 deletions server/vb/migrations/0003_allow_for_no_prize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.0.3 on 2024-04-25 00:19

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('vb', '0002_rename_gift_card'),
]

operations = [
migrations.AlterField(
model_name='contestentry',
name='amount',
field=models.IntegerField(default=0, help_text='The USD amount of the gift card. 0 means no gift card.'),
),
migrations.AlterField(
model_name='contestentry',
name='creation_request_id',
field=models.CharField(blank=True, default='', help_text='The creation code for the gift card, if a prize was won..', max_length=255),
),
]
18 changes: 18 additions & 0 deletions server/vb/migrations/0004_index_contest_entry_times.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.3 on 2024-04-25 16:12

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('vb', '0003_allow_for_no_prize'),
]

operations = [
migrations.AlterField(
model_name='contestentry',
name='created_at',
field=models.DateTimeField(auto_now_add=True, db_index=True),
),
]
24 changes: 24 additions & 0 deletions server/vb/migrations/0005_rename_related_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 5.0.3 on 2024-04-25 18:40

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('vb', '0004_index_contest_entry_times'),
]

operations = [
migrations.AlterField(
model_name='contestentry',
name='contest',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contest_entries', to='vb.contest'),
),
migrations.AlterField(
model_name='contestentry',
name='student',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contest_entries', to='vb.student'),
),
]
17 changes: 17 additions & 0 deletions server/vb/migrations/0006_alter_contestentry_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.0.3 on 2024-04-25 18:57

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('vb', '0005_rename_related_fields'),
]

operations = [
migrations.AlterModelOptions(
name='contestentry',
options={'verbose_name_plural': 'Contest entries'},
),
]
Loading

0 comments on commit 0a2b176

Please sign in to comment.