From eb054890ab6be60a4de588961a73abd3782f5238 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 17 Apr 2024 08:20:10 -0700 Subject: [PATCH 01/19] Add a .env.sample; fix env var names for AGCOD (not ACGOD!) --- .env.sample | 14 ++++++++++++++ README.md | 1 + server/settings.py | 4 ++-- server/utils/agcod.py | 8 ++++---- .../vb/management/commands/get_available_funds.py | 4 ++-- 5 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 .env.sample diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..b8a1f37 --- /dev/null +++ b/.env.sample @@ -0,0 +1,14 @@ +export BASE_URL=http://localhost:8000 +export SECRET_KEY=super-sekret +export DEBUG=true +export DATABASE_URL=postgres://:password@localhost:5432/postgres +export DJANGO_SUPERUSER_USERNAME=dev@frontseat.org +export DJANGO_SUPERUSER_EMAIL=dev@frontseat.org +export DJANGO_SUPERUSER_PASSWORD=ultrasekret + +# Enable these for AGCOD integration. See 1Password. +# export AWS_ACCESS_KEY_ID= +# export AWS_SECRET_ACCESS_KEY= +# export AWS_REGION=us-east-1 +# export AGCOD_ENDPOINT_HOST=agcod-v2-gamma.amazon.com +# export AGCOD_PARTNER_ID= diff --git a/README.md b/README.md index 7533328..b6f0fae 100644 --- a/README.md +++ b/README.md @@ -30,4 +30,5 @@ For code cleanliness, we also use: 1. Create and enable a python virtualenv with `python -m venv .venv; source .venv/bin/activate` 1. Install the python dependencies with `pip install -r requirements.txt` or `pip install ".[dev]"` 1. Get postgres set up. If you've got docker installed, `./scripts/dockerpg.sh up` +1. Configure your environment variables. (See `.env.sample` and `settings.py`) 1. Run the app. `./manage.py runserver` and visit http://localhost:8000/ diff --git a/server/settings.py b/server/settings.py index 5ddfc61..5394623 100644 --- a/server/settings.py +++ b/server/settings.py @@ -160,8 +160,8 @@ AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID") AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY") AWS_REGION = os.getenv("AWS_REGION") -ACGOD_ENDPOINT_HOST = os.getenv("ACGOD_ENDPOINT_HOST") -ACGOD_PARTNER_ID = os.getenv("ACGOD_PARTNER_ID") +AGCOD_ENDPOINT_HOST = os.getenv("AGCOD_ENDPOINT_HOST") +AGCOD_PARTNER_ID = os.getenv("AGCOD_PARTNER_ID") # Sandbox endpoint: agcod-v2-gamma.amazon.com us-east-1 # Production endpoint: agcod-v2.amazon.com us-east-1 diff --git a/server/utils/agcod.py b/server/utils/agcod.py index 43a5bb4..f19a173 100644 --- a/server/utils/agcod.py +++ b/server/utils/agcod.py @@ -291,8 +291,8 @@ def from_settings(cls) -> t.Self: aws_access_key_id = t.cast(str | None, settings.AWS_ACCESS_KEY_ID) aws_secret_access_key = t.cast(str | None, settings.AWS_SECRET_ACCESS_KEY) aws_region = t.cast(str | None, settings.AWS_REGION) - endpoint_host = t.cast(str | None, settings.ACGOD_ENDPOINT_HOST) - parter_id = t.cast(str | None, settings.ACGOD_PARTNER_ID) + endpoint_host = t.cast(str | None, settings.AGCOD_ENDPOINT_HOST) + parter_id = t.cast(str | None, settings.AGCOD_PARTNER_ID) if aws_access_key_id is None: logger.warning("Missing AWS_ACCESS_KEY_ID") @@ -304,10 +304,10 @@ def from_settings(cls) -> t.Self: logger.warning("Missing AWS_REGION") if endpoint_host is None: - logger.warning("Missing ACGOD_ENDPOINT_HOST") + logger.warning("Missing AGCOD_ENDPOINT_HOST") if parter_id is None: - logger.warning("Missing ACGOD_PARTNER_ID") + logger.warning("Missing AGCOD_PARTNER_ID") if None in ( aws_access_key_id, diff --git a/server/vb/management/commands/get_available_funds.py b/server/vb/management/commands/get_available_funds.py index aa8f2cc..580287c 100644 --- a/server/vb/management/commands/get_available_funds.py +++ b/server/vb/management/commands/get_available_funds.py @@ -4,10 +4,10 @@ class Command(BaseCommand): - """Get available funds remaining for generating gift codes using the ACGOD API.""" + """Get available funds remaining for generating gift codes using the AGCOD API.""" help = ( - "Get available funds remaining for generating gift codes using the ACGOD API." + "Get available funds remaining for generating gift codes using the AGCOD API." ) def handle(self, **options): From 3041e6d156944723cb3ecfde2536dd06c03a1d97 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 17 Apr 2024 10:22:24 -0700 Subject: [PATCH 02/19] Annoying. --- .vscode/settings.json | 3 +++ jsconfig.json | 10 ++++++++ server/static/js/utils.js | 24 +++++++++++++++++++ server/vb/templates/base.dhtml | 1 + .../vb/templates/components/countdown.dhtml | 12 +--------- 5 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 jsconfig.json create mode 100644 server/static/js/utils.js diff --git a/.vscode/settings.json b/.vscode/settings.json index 8c4039e..01bfcc4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,5 +15,8 @@ "editor.codeActionsOnSave": { "source.fixAll": "explicit" } + }, + "emmet.includeLanguages": { + "django-html": "html" } } diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..8a8b426 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "CommonJS", + "checkJs": true, + "allowJs": true, + "noEmit": true + }, + "include": ["server/static/js/utils.js", "*.dhtml"] +} diff --git a/server/static/js/utils.js b/server/static/js/utils.js new file mode 100644 index 0000000..128fd0a --- /dev/null +++ b/server/static/js/utils.js @@ -0,0 +1,24 @@ +/** + * VoterBowl utility functions accessible globally via $vb. + * + * @namespace $vb + */ +globalThis.$vb = { + /** + * Invoke the callback exactly once, when the DOM is ready. + * + * @param {Function} callback + * @returns {void} + */ + onReady: (callback) => { + if (document.readyState !== 'loading') { + callback(); + } else { + const listener = () => { + document.removeEventListener('DOMContentLoaded', listener); + callback(); + } + document.addEventListener('DOMContentLoaded', listener); + } + } +}; diff --git a/server/vb/templates/base.dhtml b/server/vb/templates/base.dhtml index 684b16e..77bd59c 100644 --- a/server/vb/templates/base.dhtml +++ b/server/vb/templates/base.dhtml @@ -21,6 +21,7 @@ + {% django_htmx_script %} diff --git a/server/vb/templates/components/countdown.dhtml b/server/vb/templates/components/countdown.dhtml index bcb10d3..d129fe6 100644 --- a/server/vb/templates/components/countdown.dhtml +++ b/server/vb/templates/components/countdown.dhtml @@ -46,14 +46,6 @@ {# djlint:on #}

{{ contest.name }} ends in

From 8520bc7fe6b29ae4036a197cdc7d9f5e1556cac3 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 17 Apr 2024 11:57:07 -0700 Subject: [PATCH 03/19] Clean up a lot of details related to the weird CSS/JS libs I'm using. --- .vscode/settings.json | 7 +- jsconfig.json | 5 +- server/static/js/utils.js | 24 --- server/vb/templates/base.dhtml | 9 +- .../vb/templates/components/countdown.dhtml | 137 +++++++++++------- .../templates/components/logo_specimen.dhtml | 12 +- server/vb/templates/home.dhtml | 2 +- server/vb/templates/includes/faq.dhtml | 49 ++++--- server/vb/templates/school.dhtml | 14 +- 9 files changed, 136 insertions(+), 123 deletions(-) delete mode 100644 server/static/js/utils.js diff --git a/.vscode/settings.json b/.vscode/settings.json index 01bfcc4..419765a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,14 +9,13 @@ "source.organizeImports.ruff": "explicit" } }, - "[html][django-html]": { - "editor.defaultFormatter": "monosans.djlint", + "[html]": { "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll": "explicit" } }, - "emmet.includeLanguages": { - "django-html": "html" + "files.associations": { + "*.dhtml": "html" } } diff --git a/jsconfig.json b/jsconfig.json index 8a8b426..f67ec8e 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -6,5 +6,8 @@ "allowJs": true, "noEmit": true }, - "include": ["server/static/js/utils.js", "*.dhtml"] + "include": [ + "server/static/js/surreal.js", + "server/static/js/css-scope-inline.js" + ] } diff --git a/server/static/js/utils.js b/server/static/js/utils.js deleted file mode 100644 index 128fd0a..0000000 --- a/server/static/js/utils.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * VoterBowl utility functions accessible globally via $vb. - * - * @namespace $vb - */ -globalThis.$vb = { - /** - * Invoke the callback exactly once, when the DOM is ready. - * - * @param {Function} callback - * @returns {void} - */ - onReady: (callback) => { - if (document.readyState !== 'loading') { - callback(); - } else { - const listener = () => { - document.removeEventListener('DOMContentLoaded', listener); - callback(); - } - document.addEventListener('DOMContentLoaded', listener); - } - } -}; diff --git a/server/vb/templates/base.dhtml b/server/vb/templates/base.dhtml index 77bd59c..b764a34 100644 --- a/server/vb/templates/base.dhtml +++ b/server/vb/templates/base.dhtml @@ -18,10 +18,9 @@ - - - - + + + {% django_htmx_script %} @@ -30,7 +29,7 @@ VoterBowl, coming soon.
- {# djlint:off #} - - {# djlint:on #} - -

{{ contest.name }} ends in

-
+ const self = me(); + onloadAdd(() => countdown(self)); +     : @@ -102,4 +127,4 @@    
-
+
\ No newline at end of file diff --git a/server/vb/templates/components/logo_specimen.dhtml b/server/vb/templates/components/logo_specimen.dhtml index 55e5509..f41e154 100644 --- a/server/vb/templates/components/logo_specimen.dhtml +++ b/server/vb/templates/components/logo_specimen.dhtml @@ -1,25 +1,25 @@ {% with width=width|default:"32px" height=height|default:"32px" %}
diff --git a/server/vb/templates/includes/faq.dhtml b/server/vb/templates/includes/faq.dhtml index 786cdb3..65dd190 100644 --- a/server/vb/templates/includes/faq.dhtml +++ b/server/vb/templates/includes/faq.dhtml @@ -1,54 +1,66 @@

F.A.Q.

What is the Voter Bowl?

- The Voter Bowl is a series of contests where college students win prizes + The Voter Bowl is a series of contests where college students win prizes by making sure they’re ready to vote.

- The Voter Bowl is a non-profit project of - VoteAmerica, a + The Voter Bowl is a non-profit project of + VoteAmerica, a leader in voter registration and participation.

How do I claim my prize?

- When you check your voter registration status by clicking the “Check My - Voter Status” while a contest is running, you are automatically entered to + When you check your voter registration status by clicking the “Check My + Voter Status” while a contest is running, you are automatically entered to win. If you win, we will notify you via your school email address.

@@ -58,24 +70,23 @@

What is the goal of the Voter Bowl?

- In the 2020 election, 33% of college students didn’t vote. We believe a + In the 2020 election, 33% of college students didn’t vote. We believe a healthy democracy depends on young people participating.

Who is paying for the Voter Bowl?

- VoteAmerica is - funding the Voter Bowl through the generous support of donors who are - passionate about building a thriving democracy. + VoteAmerica is + funding the Voter Bowl through the generous support of donors who are + passionate about building a thriving democracy.

-

- - Donate to VoteAmerica to support projects like this. +

+ Donate to VoteAmerica to support projects like this.

-
+

I have another question.

Contact us and we'd be happy to answer it. diff --git a/server/vb/templates/school.dhtml b/server/vb/templates/school.dhtml index d8ce226..3bcefd9 100644 --- a/server/vb/templates/school.dhtml +++ b/server/vb/templates/school.dhtml @@ -7,44 +7,44 @@ {% block body %}

+
+
+ {% if current_contest %} + {% include "components/countdown.dhtml" with contest=current_contest %} + {% endif %} + {{ school.short_name }} {{ school.mascot }} logo +

Welcome to the Voter Bowl

+ {% if current_contest %} +

{{ current_contest.description }}

+ {% else %} +

There is no contest at this time. TODO: show something useful in this state?

+ {% endif %} +
+ {% include "components/button.dhtml" with text="Check my voter status" href="./check/" bg_color=school.logo.action_color color=school.logo.action_text_color %} +
+
+
+
+
{% include "includes/faq.dhtml" %}
+
+
+{% endblock body %} diff --git a/server/vb/templates/components/button.dhtml b/server/vb/templates/components/button.dhtml index a331acf..5c6f3a2 100644 --- a/server/vb/templates/components/button.dhtml +++ b/server/vb/templates/components/button.dhtml @@ -1,9 +1,12 @@ - + diff --git a/server/vb/templates/components/logo_specimen.dhtml b/server/vb/templates/components/logo_specimen.dhtml index f41e154..22f1881 100644 --- a/server/vb/templates/components/logo_specimen.dhtml +++ b/server/vb/templates/components/logo_specimen.dhtml @@ -1,60 +1,57 @@ {% with width=width|default:"32px" height=height|default:"32px" %} -
- -
- TODO alt -
-
+
+
text
-
action
-
-{% endwith %} +
+{% endwith %} \ No newline at end of file diff --git a/server/vb/templates/includes/faq.dhtml b/server/vb/templates/includes/faq.dhtml index 9a27471..68f7240 100644 --- a/server/vb/templates/includes/faq.dhtml +++ b/server/vb/templates/includes/faq.dhtml @@ -83,7 +83,7 @@

Donate to VoteAmerica to support projects like this. + target="_blank">Donate to VoteAmerica to support projects like this.

@@ -92,4 +92,4 @@ Contact us and we'd be happy to answer it.

-
\ No newline at end of file +
diff --git a/server/vb/urls.py b/server/vb/urls.py index 9090e4a..760555e 100644 --- a/server/vb/urls.py +++ b/server/vb/urls.py @@ -1,8 +1,9 @@ from django.urls import path -from .views import home, school +from .views import check, home, school urlpatterns = [ + path("/check/", check, name="check"), path("/", school, name="school"), path("", home), ] diff --git a/server/vb/views.py b/server/vb/views.py index be72cb9..14f5659 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -19,3 +19,13 @@ def school(request: HttpRequest, slug: str) -> HttpResponse: return render( request, "school.dhtml", {"school": school, "current_contest": current_contest} ) + + +@require_GET +def check(request: HttpRequest, slug: str) -> HttpResponse: + """Render a school-specific check registration page.""" + school = get_object_or_404(School, slug=slug) + current_contest = school.contests.current() + return render( + request, "check.dhtml", {"school": school, "current_contest": current_contest} + ) From 15b3dabc20a51ae752126b2720a334076747de45 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 17 Apr 2024 12:27:28 -0700 Subject: [PATCH 07/19] More annoyances. --- .../vb/templates/components/countdown.dhtml | 116 +++++++++--------- server/vb/templates/school.dhtml | 23 +++- 2 files changed, 80 insertions(+), 59 deletions(-) diff --git a/server/vb/templates/components/countdown.dhtml b/server/vb/templates/components/countdown.dhtml index 505f4e7..a289453 100644 --- a/server/vb/templates/components/countdown.dhtml +++ b/server/vb/templates/components/countdown.dhtml @@ -14,9 +14,11 @@ }

{{ contest.name }} ends in

-
+
    @@ -124,4 +128,4 @@    
-
\ No newline at end of file +
diff --git a/server/vb/templates/school.dhtml b/server/vb/templates/school.dhtml index 3bcefd9..0194e38 100644 --- a/server/vb/templates/school.dhtml +++ b/server/vb/templates/school.dhtml @@ -50,8 +50,25 @@ margin: 1.5rem 0; } -
+
+ +
{% if current_contest %} {% include "components/countdown.dhtml" with contest=current_contest %} @@ -65,7 +82,7 @@

There is no contest at this time. TODO: show something useful in this state?

{% endif %}
- {% include "components/button.dhtml" with text="Check my voter status" bg_color=school.logo.action_color color=school.logo.action_text_color %} + {% include "components/button.dhtml" with text="Check my voter status" href="./check/" bg_color=school.logo.action_color color=school.logo.action_text_color %}
From 71a53342177ef1f3f995185c9dfa9c972d321f5f Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 17 Apr 2024 12:32:44 -0700 Subject: [PATCH 08/19] Check the check --- server/vb/templates/check.dhtml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/server/vb/templates/check.dhtml b/server/vb/templates/check.dhtml index abf2889..0194e38 100644 --- a/server/vb/templates/check.dhtml +++ b/server/vb/templates/check.dhtml @@ -50,8 +50,25 @@ margin: 1.5rem 0; } -
+
+ +
{% if current_contest %} {% include "components/countdown.dhtml" with contest=current_contest %} From 8954d57f2d4ef286bfc43a0ff2bf8cbadf5c8b30 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 17 Apr 2024 16:16:57 -0700 Subject: [PATCH 09/19] Add fireworks! --- server/static/css/base.css | 2 - server/static/js/fireworks.js | 8 ++ server/vb/templates/base.dhtml | 4 + server/vb/templates/check.dhtml | 83 +++++++++++++++---- .../vb/templates/components/countdown.dhtml | 21 +++-- server/vb/templates/school.dhtml | 2 +- 6 files changed, 93 insertions(+), 27 deletions(-) create mode 100644 server/static/js/fireworks.js diff --git a/server/static/css/base.css b/server/static/css/base.css index eb8875e..0d604e5 100644 --- a/server/static/css/base.css +++ b/server/static/css/base.css @@ -9,8 +9,6 @@ html { font-size: 16px; font-family: var(--font-sans); - background-color: black; - color: white; } diff --git a/server/static/js/fireworks.js b/server/static/js/fireworks.js new file mode 100644 index 0000000..1a54f06 --- /dev/null +++ b/server/static/js/fireworks.js @@ -0,0 +1,8 @@ +/** + * name: fireworks-js + * version: 2.10.7 + * author: Vitalij Ryndin (https://crashmax.ru) + * homepage: https://fireworks.js.org + * license MIT + */ +(function(c,u){typeof exports=="object"&&typeof module<"u"?u(exports):typeof define=="function"&&define.amd?define(["exports"],u):(c=typeof globalThis<"u"?globalThis:c||self,u(c.Fireworks={}))})(this,function(c){"use strict";function u(e){return Math.abs(Math.floor(e))}function p(e,t){return Math.random()*(t-e)+e}function o(e,t){return Math.floor(p(e,t+1))}function g(e,t,i,s){const n=Math.pow;return Math.sqrt(n(e-i,2)+n(t-s,2))}function f(e,t,i=1){if(e>360||e<0)throw new Error(`Expected hue 0-360 range, got \`${e}\``);if(t>100||t<0)throw new Error(`Expected lightness 0-100 range, got \`${t}\``);if(i>1||i<0)throw new Error(`Expected alpha 0-1 range, got \`${i}\``);return`hsla(${e}, 100%, ${t}%, ${i})`}const v=e=>{if(typeof e=="object"&&e!==null){if(typeof Object.getPrototypeOf=="function"){const t=Object.getPrototypeOf(e);return t===Object.prototype||t===null}return Object.prototype.toString.call(e)==="[object Object]"}return!1},b=["__proto__","constructor","prototype"],w=(...e)=>e.reduce((t,i)=>(Object.keys(i).forEach(s=>{b.includes(s)||(Array.isArray(t[s])&&Array.isArray(i[s])?t[s]=i[s]:v(t[s])&&v(i[s])?t[s]=w(t[s],i[s]):t[s]=i[s])}),t),{});function S(e,t){let i;return(...s)=>{i&&clearTimeout(i),i=setTimeout(()=>e(...s),t)}}class O{x;y;ctx;hue;friction;gravity;flickering;lineWidth;explosionLength;angle;speed;brightness;coordinates=[];decay;alpha=1;constructor({x:t,y:i,ctx:s,hue:n,decay:h,gravity:a,friction:r,brightness:l,flickering:d,lineWidth:x,explosionLength:m}){for(this.x=t,this.y=i,this.ctx=s,this.hue=n,this.gravity=a,this.friction=r,this.flickering=d,this.lineWidth=x,this.explosionLength=m,this.angle=p(0,Math.PI*2),this.speed=o(1,10),this.brightness=o(l.min,l.max),this.decay=p(h.min,h.max);this.explosionLength--;)this.coordinates.push([t,i])}update(t){this.coordinates.pop(),this.coordinates.unshift([this.x,this.y]),this.speed*=this.friction,this.x+=Math.cos(this.angle)*this.speed,this.y+=Math.sin(this.angle)*this.speed+this.gravity,this.alpha-=this.decay,this.alpha<=this.decay&&t()}draw(){const t=this.coordinates.length-1;this.ctx.beginPath(),this.ctx.lineWidth=this.lineWidth,this.ctx.fillStyle=f(this.hue,this.brightness,this.alpha),this.ctx.moveTo(this.coordinates[t][0],this.coordinates[t][1]),this.ctx.lineTo(this.x,this.y),this.ctx.strokeStyle=f(this.hue,this.flickering?p(0,this.brightness):this.brightness,this.alpha),this.ctx.stroke()}}class E{constructor(t,i){this.options=t,this.canvas=i,this.pointerDown=this.pointerDown.bind(this),this.pointerUp=this.pointerUp.bind(this),this.pointerMove=this.pointerMove.bind(this)}active=!1;x;y;get mouseOptions(){return this.options.mouse}mount(){this.canvas.addEventListener("pointerdown",this.pointerDown),this.canvas.addEventListener("pointerup",this.pointerUp),this.canvas.addEventListener("pointermove",this.pointerMove)}unmount(){this.canvas.removeEventListener("pointerdown",this.pointerDown),this.canvas.removeEventListener("pointerup",this.pointerUp),this.canvas.removeEventListener("pointermove",this.pointerMove)}usePointer(t,i){const{click:s,move:n}=this.mouseOptions;(s||n)&&(this.x=t.pageX-this.canvas.offsetLeft,this.y=t.pageY-this.canvas.offsetTop,this.active=i)}pointerDown(t){this.usePointer(t,this.mouseOptions.click)}pointerUp(t){this.usePointer(t,!1)}pointerMove(t){this.usePointer(t,this.active)}}class M{hue;rocketsPoint;opacity;acceleration;friction;gravity;particles;explosion;mouse;boundaries;sound;delay;brightness;decay;flickering;intensity;traceLength;traceSpeed;lineWidth;lineStyle;autoresize;constructor(){this.autoresize=!0,this.lineStyle="round",this.flickering=50,this.traceLength=3,this.traceSpeed=10,this.intensity=30,this.explosion=5,this.gravity=1.5,this.opacity=.5,this.particles=50,this.friction=.95,this.acceleration=1.05,this.hue={min:0,max:360},this.rocketsPoint={min:50,max:50},this.lineWidth={explosion:{min:1,max:3},trace:{min:1,max:2}},this.mouse={click:!1,move:!1,max:1},this.delay={min:30,max:60},this.brightness={min:50,max:80},this.decay={min:.015,max:.03},this.sound={enabled:!1,files:["explosion0.mp3","explosion1.mp3","explosion2.mp3"],volume:{min:4,max:8}},this.boundaries={debug:!1,height:0,width:0,x:50,y:50}}update(t){Object.assign(this,w(this,t))}}class z{constructor(t,i){this.options=t,this.render=i}tick=0;rafId=0;fps=60;tolerance=.1;now;mount(){this.now=performance.now();const t=1e3/this.fps,i=s=>{this.rafId=requestAnimationFrame(i);const n=s-this.now;n>=t-this.tolerance&&(this.render(),this.now=s-n%t,this.tick+=n*(this.options.intensity*Math.PI)/1e3)};this.rafId=requestAnimationFrame(i)}unmount(){cancelAnimationFrame(this.rafId)}}class L{constructor(t,i,s){this.options=t,this.updateSize=i,this.container=s}resizer;mount(){if(!this.resizer){const t=S(()=>this.updateSize(),100);this.resizer=new ResizeObserver(t)}this.options.autoresize&&this.resizer.observe(this.container)}unmount(){this.resizer&&this.resizer.unobserve(this.container)}}class T{constructor(t){this.options=t,this.init()}buffers=[];audioContext;onInit=!1;get isEnabled(){return this.options.sound.enabled}get soundOptions(){return this.options.sound}init(){!this.onInit&&this.isEnabled&&(this.onInit=!0,this.audioContext=new(window.AudioContext||window.webkitAudioContext),this.loadSounds())}async loadSounds(){for(const t of this.soundOptions.files){const i=await(await fetch(t)).arrayBuffer();this.audioContext.decodeAudioData(i).then(s=>{this.buffers.push(s)}).catch(s=>{throw s})}}play(){if(this.isEnabled&&this.buffers.length){const t=this.audioContext.createBufferSource(),i=this.buffers[o(0,this.buffers.length-1)],s=this.audioContext.createGain();t.buffer=i,s.gain.value=p(this.soundOptions.volume.min/100,this.soundOptions.volume.max/100),s.connect(this.audioContext.destination),t.connect(s),t.start(0)}else this.init()}}class C{x;y;sx;sy;dx;dy;ctx;hue;speed;acceleration;traceLength;totalDistance;angle;brightness;coordinates=[];currentDistance=0;constructor({x:t,y:i,dx:s,dy:n,ctx:h,hue:a,speed:r,traceLength:l,acceleration:d}){for(this.x=t,this.y=i,this.sx=t,this.sy=i,this.dx=s,this.dy=n,this.ctx=h,this.hue=a,this.speed=r,this.traceLength=l,this.acceleration=d,this.totalDistance=g(t,i,s,n),this.angle=Math.atan2(n-i,s-t),this.brightness=o(50,70);this.traceLength--;)this.coordinates.push([t,i])}update(t){this.coordinates.pop(),this.coordinates.unshift([this.x,this.y]),this.speed*=this.acceleration;const i=Math.cos(this.angle)*this.speed,s=Math.sin(this.angle)*this.speed;this.currentDistance=g(this.sx,this.sy,this.x+i,this.y+s),this.currentDistance>=this.totalDistance?t(this.dx,this.dy,this.hue):(this.x+=i,this.y+=s)}draw(){const t=this.coordinates.length-1;this.ctx.beginPath(),this.ctx.moveTo(this.coordinates[t][0],this.coordinates[t][1]),this.ctx.lineTo(this.x,this.y),this.ctx.strokeStyle=f(this.hue,this.brightness),this.ctx.stroke()}}class y{target;container;canvas;ctx;width;height;traces=[];explosions=[];waitStopRaf;running=!1;opts;sound;resize;mouse;raf;constructor(t,i={}){this.target=t,this.container=t,this.opts=new M,this.createCanvas(this.target),this.updateOptions(i),this.sound=new T(this.opts),this.resize=new L(this.opts,this.updateSize.bind(this),this.container),this.mouse=new E(this.opts,this.canvas),this.raf=new z(this.opts,this.render.bind(this))}get isRunning(){return this.running}get version(){return"2.10.7"}get currentOptions(){return this.opts}start(){this.running||(this.canvas.isConnected||this.createCanvas(this.target),this.running=!0,this.resize.mount(),this.mouse.mount(),this.raf.mount())}stop(t=!1){!this.running||(this.running=!1,this.resize.unmount(),this.mouse.unmount(),this.raf.unmount(),this.clear(),t&&this.canvas.remove())}async waitStop(t){if(!!this.running)return new Promise(i=>{this.waitStopRaf=()=>{!this.waitStopRaf||(requestAnimationFrame(this.waitStopRaf),!this.traces.length&&!this.explosions.length&&(this.waitStopRaf=null,this.stop(t),i()))},this.waitStopRaf()})}pause(){this.running=!this.running,this.running?this.raf.mount():this.raf.unmount()}clear(){!this.ctx||(this.traces=[],this.explosions=[],this.ctx.clearRect(0,0,this.width,this.height))}launch(t=1){for(let i=0;io(t.min,t.max)||this.mouse.active&&i.max>this.traces.length)&&(this.createTrace(),this.raf.tick=0)}drawTrace(){let t=this.traces.length;for(;t--;)this.traces[t].draw(),this.traces[t].update((i,s,n)=>{this.initExplosion(i,s,n),this.sound.play(),this.traces.splice(t,1)})}initExplosion(t,i,s){const{particles:n,flickering:h,lineWidth:a,explosion:r,brightness:l,friction:d,gravity:x,decay:m}=this.opts;let P=u(n);for(;P--;)this.explosions.push(new O({x:t,y:i,ctx:this.ctx,hue:s,friction:d,gravity:x,flickering:o(0,100)<=h,lineWidth:p(a.explosion.min,a.explosion.max),explosionLength:u(r),brightness:l,decay:m}))}drawExplosion(){let t=this.explosions.length;for(;t--;)this.explosions[t].draw(),this.explosions[t].update(()=>{this.explosions.splice(t,1)})}}c.Fireworks=y,c.default=y,Object.defineProperties(c,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})}); diff --git a/server/vb/templates/base.dhtml b/server/vb/templates/base.dhtml index b764a34..7eafbce 100644 --- a/server/vb/templates/base.dhtml +++ b/server/vb/templates/base.dhtml @@ -22,6 +22,10 @@ {% django_htmx_script %} + + {% block head_extras %} + {% endblock head_extras %} + diff --git a/server/vb/templates/check.dhtml b/server/vb/templates/check.dhtml index 0194e38..64859ab 100644 --- a/server/vb/templates/check.dhtml +++ b/server/vb/templates/check.dhtml @@ -1,10 +1,21 @@ {% extends "base.dhtml" %} +{% load static %} {% block title %} - Voter Bowl + Voter Bowl x {{ school.short_name }} {% endblock title %} +{% block head_extras %} + + +{% endblock head_extras %} + {% block body %} +
@@ -56,9 +73,23 @@ me { --main-color: transparent; --main-bg-color: transparent; + position: relative; color: var(--main-color); background-color: var(--main-bg-color); } + + me .urgency { + display: flex; + } + + me .fireworks { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + overflow: hidden; + }
- {% if current_contest %} - {% include "components/countdown.dhtml" with contest=current_contest %} - {% endif %} - {{ school.short_name }} {{ school.mascot }} logo -

Welcome to the Voter Bowl

- {% if current_contest %} -

{{ current_contest.description }}

- {% else %} -

There is no contest at this time. TODO: show something useful in this state?

- {% endif %} -
- {% include "components/button.dhtml" with text="Check my voter status" href="./check/" bg_color=school.logo.action_color color=school.logo.action_text_color %} +
+ {{ school.short_name }} {{ school.mascot }} logo + {% if current_contest %} + {% include "components/countdown.dhtml" with contest=current_contest %} + {% endif %}
+
-
-
{% include "includes/faq.dhtml" %}
+
+
+ +
+
{% endblock body %} diff --git a/server/vb/templates/components/countdown.dhtml b/server/vb/templates/components/countdown.dhtml index a289453..c770c3f 100644 --- a/server/vb/templates/components/countdown.dhtml +++ b/server/vb/templates/components/countdown.dhtml @@ -72,14 +72,6 @@ self.style.setProperty('--number-bg-color', self.dataset.numberBgColor); self.style.setProperty('--colon-color', self.dataset.colonColor); - // get the number elements - const h0 = self.querySelector('[data-number=h0]'); - const h1 = self.querySelector('[data-number=h1]'); - const m0 = self.querySelector('[data-number=m0]'); - const m1 = self.querySelector('[data-number=m1]'); - const s0 = self.querySelector('[data-number=s0]'); - const s1 = self.querySelector('[data-number=s1]'); - const numbers = [h0, h1, m0, m1, s0, s1]; /** Update the countdown. */ function updateCountdown() { @@ -92,6 +84,19 @@ return; } + // get the number elements + const h0 = self.querySelector('[data-number=h0]'); + const h1 = self.querySelector('[data-number=h1]'); + const m0 = self.querySelector('[data-number=m0]'); + const m1 = self.querySelector('[data-number=m1]'); + const s0 = self.querySelector('[data-number=s0]'); + const s1 = self.querySelector('[data-number=s1]'); + const numbers = [h0, h1, m0, m1, s0, s1]; + + if (numbers.some(number => !number)) { + return; + } + const hours = Math.floor(diff / (1000 * 60 * 60)); const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); const seconds = Math.floor((diff % (1000 * 60)) / 1000); diff --git a/server/vb/templates/school.dhtml b/server/vb/templates/school.dhtml index 0194e38..4671a5a 100644 --- a/server/vb/templates/school.dhtml +++ b/server/vb/templates/school.dhtml @@ -1,7 +1,7 @@ {% extends "base.dhtml" %} {% block title %} - Voter Bowl + Voter Bowl x {{ school.short_name }} {% endblock title %} {% block body %} From 19b682b33fb1203b371fe4c155e2e37d2fac218b Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 17 Apr 2024 17:18:51 -0700 Subject: [PATCH 10/19] Some dope shizz is going down. PERMISSION DENIED. --- server/vb/templates/check.dhtml | 238 +++++++++++++------------ server/vb/templates/finish_check.dhtml | 3 + server/vb/urls.py | 3 +- server/vb/views.py | 38 +++- 4 files changed, 168 insertions(+), 114 deletions(-) create mode 100644 server/vb/templates/finish_check.dhtml diff --git a/server/vb/templates/check.dhtml b/server/vb/templates/check.dhtml index 64859ab..e53a58c 100644 --- a/server/vb/templates/check.dhtml +++ b/server/vb/templates/check.dhtml @@ -2,143 +2,157 @@ {% load static %} {% block title %} - Voter Bowl x {{ school.short_name }} +Voter Bowl x {{ school.short_name }} {% endblock title %} {% block head_extras %} - - + + {% endblock head_extras %} {% block body %} + +
-
- +
+ -
- - -
-
- {{ school.short_name }} {{ school.mascot }} logo - {% if current_contest %} - {% include "components/countdown.dhtml" with contest=current_contest %} - {% endif %} -
-
-
-
-
-
- -
+ const fireworks = new Fireworks.default(self.querySelector('.fireworks')); + fireworks.start(); + finishVerify(data.first_name, data.last_name, data.email); + }, 500); + } + }); + })(me()); + +
+
+ {{ school.short_name }} {{ school.mascot }} logo + {% if current_contest %} + {% include "components/countdown.dhtml" with contest=current_contest %} + {% endif %}
+
+
+
+
+ +
+
-{% endblock body %} +
+{% endblock body %} \ No newline at end of file diff --git a/server/vb/templates/finish_check.dhtml b/server/vb/templates/finish_check.dhtml new file mode 100644 index 0000000..26dee5f --- /dev/null +++ b/server/vb/templates/finish_check.dhtml @@ -0,0 +1,3 @@ +{{ school.short_name }} {{ school.mascot }} logo +

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

diff --git a/server/vb/urls.py b/server/vb/urls.py index 760555e..c5e2781 100644 --- a/server/vb/urls.py +++ b/server/vb/urls.py @@ -1,8 +1,9 @@ from django.urls import path -from .views import check, home, school +from .views import check, finish_check, home, school urlpatterns = [ + path("/check/finish/", finish_check, name="finish_check"), path("/check/", check, name="check"), path("/", school, name="school"), path("", home), diff --git a/server/vb/views.py b/server/vb/views.py index 14f5659..7a008e1 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -1,6 +1,9 @@ +from django import forms +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.http import require_GET +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET, require_POST from .models import School @@ -29,3 +32,36 @@ def check(request: HttpRequest, slug: str) -> HttpResponse: return render( request, "check.dhtml", {"school": school, "current_contest": current_contest} ) + + +class VerifyForm(forms.Form): + """Form for verifying a check.""" + + first_name = forms.CharField(max_length=100) + last_name = forms.CharField(max_length=100) + email = forms.EmailField() + + +@require_POST +@csrf_exempt # CONSIDER: maybe use Django's CSRF protection even here? +def finish_check(request: HttpRequest, slug: str) -> HttpResponse: + """Handle a check registration form submission.""" + school = get_object_or_404(School, slug=slug) + current_contest = school.contests.current() + if not current_contest: + raise ValueError("No active contest TODO") + form = VerifyForm(request.POST) + if not form.is_valid(): + raise PermissionDenied("Invalid form") + + return render( + request, + "finish_check.dhtml", + { + "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"], + }, + ) From 8e4511a59c1ee09fadf8b6d762856d7fcfff4eda Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 18 Apr 2024 14:29:18 -0700 Subject: [PATCH 11/19] 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, }, ) From 437f682af31b6fac4441d60ce9c0ce7a645ccfbc Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 18 Apr 2024 17:33:35 -0700 Subject: [PATCH 12/19] Good enough email template, for now. --- .env.sample | 9 + server/settings.py | 29 ++ server/utils/email.py | 70 +++++ server/vb/models.py | 2 +- server/vb/ops.py | 16 +- server/vb/templates/email/base/body.dhtml | 272 ++++++++++++++++++ server/vb/templates/email/base/body.txt | 9 + server/vb/templates/email/base/button.html | 79 +++++ server/vb/templates/email/base/center.html | 3 + .../vb/templates/email/base/center_close.html | 3 + server/vb/templates/email/base/p.html | 2 + server/vb/templates/email/base/p_close.html | 2 + server/vb/templates/email/validate/body.dhtml | 11 + server/vb/templates/email/validate/body.txt | 10 + .../vb/templates/email/validate/subject.txt | 1 + server/vb/urls.py | 4 +- server/vb/views.py | 2 +- 17 files changed, 518 insertions(+), 6 deletions(-) create mode 100644 server/vb/templates/email/base/body.dhtml create mode 100644 server/vb/templates/email/base/body.txt create mode 100644 server/vb/templates/email/base/button.html create mode 100644 server/vb/templates/email/base/center.html create mode 100644 server/vb/templates/email/base/center_close.html create mode 100644 server/vb/templates/email/base/p.html create mode 100644 server/vb/templates/email/base/p_close.html create mode 100644 server/vb/templates/email/validate/body.dhtml create mode 100644 server/vb/templates/email/validate/body.txt create mode 100644 server/vb/templates/email/validate/subject.txt diff --git a/.env.sample b/.env.sample index b8a1f37..1f62ccc 100644 --- a/.env.sample +++ b/.env.sample @@ -12,3 +12,12 @@ export DJANGO_SUPERUSER_PASSWORD=ultrasekret # export AWS_REGION=us-east-1 # export AGCOD_ENDPOINT_HOST=agcod-v2-gamma.amazon.com # export AGCOD_PARTNER_ID= + +# Enable these for Mandrill integration. See 1Password. +# export EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +# export EMAIL_HOST=smtp.mandrillapp.com +# export EMAIL_HOST_PASSWORD= +# export EMAIL_HOST_USER=VoterBowl +# export EMAIL_PORT=587 +# export EMAIL_USE_TLS=true +# export EMAIL_USE_SSL=false diff --git a/server/settings.py b/server/settings.py index 5394623..2e79400 100644 --- a/server/settings.py +++ b/server/settings.py @@ -165,3 +165,32 @@ # Sandbox endpoint: agcod-v2-gamma.amazon.com us-east-1 # Production endpoint: agcod-v2.amazon.com us-east-1 + + +# ---------------------------------------------------------------------------- +# Email Settings +# ---------------------------------------------------------------------------- + +# The email address that emails are sent from unless explicitly overridden +# when invoking Django's `send_mail` function +DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "hello@voterbowl.org") + +# The *sending* email address used when Django emails admins about errors. +# For now, we make this the same as DEFAULT_FROM_EMAIL +SERVER_EMAIL = DEFAULT_FROM_EMAIL + +# The email addresses of our administrators. +ADMINS = [("Frontseat Developers", "dev@frontseat.org")] + +# How to send email. We default to console-based email, which simply prints +# emails to the console. This is useful for local development. In production, +# we'll configure SMTP. +EMAIL_BACKEND = os.getenv( + "EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend" +) +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", None) +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", None) +EMAIL_HOST = os.getenv("EMAIL_HOST", None) +EMAIL_PORT = os.getenv("EMAIL_PORT", None) +EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "false").lower() == "true" +EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true" diff --git a/server/utils/email.py b/server/utils/email.py index 68a3bae..10f4761 100644 --- a/server/utils/email.py +++ b/server/utils/email.py @@ -1,4 +1,12 @@ import dataclasses +import logging +import typing as t + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string + +logger = logging.getLogger(__name__) @dataclasses.dataclass(frozen=True) @@ -42,3 +50,65 @@ def normalize_email( local = local.encode("ascii", "ignore").decode("ascii") domain = domain.encode("ascii", "ignore").decode("ascii") return f"{local}@{domain}" + + +def send_template_email( + to: str | t.Sequence[str], + template_base: str, + context: dict | None = None, + from_email: str | None = None, +) -> bool: + """ + Send a templatized email. + + Send an email to the `to` address, using the template files found under + the `template_base` to render contents. + + The following named templates must be found underneath `template_base`: + + - `subject.txt`: renders the subject line + - `body.txt`: renders the plain-text body + - `body.dhtml`: renders the HTML body + + Django's template system is flexible and can load templates from just about + anywhere, provided you write a plugin. But! By default, we're going to load + them from the filesystem; `template_base` is simply the name of the + directory that contains these three files, relative to the `templates` + directory in the app. + + For instance, if we have `subject`/`body` templates in + `server/assistant/templates/email/registration`, then `template_base` is + `email/registration`. + """ + to_array = [to] if isinstance(to, str) else to + + message = create_message(to_array, template_base, context, from_email) + try: + message.send() + return True + except Exception: + logger.exception(f"failed to send email to {to}") + return False + + +def create_message( + to: t.Sequence[str], + template_base: str, + context: dict[str, t.Any] | None = None, + from_email: str | None = None, +) -> EmailMultiAlternatives: + """Create the underlying email message to send.""" + context = context or {} + from_email = from_email or settings.DEFAULT_FROM_EMAIL + context.setdefault("BASE_URL", settings.BASE_URL) + + 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) + + message = EmailMultiAlternatives( + from_email=from_email, to=to, subject=subject, body=text + ) + message.attach_alternative(html, "text/html") + + return message diff --git a/server/vb/models.py b/server/vb/models.py index fe341f0..c1f320a 100644 --- a/server/vb/models.py +++ b/server/vb/models.py @@ -361,7 +361,7 @@ def school(self) -> 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]) + return reverse("vb:validate_email", args=[self.contest.school.slug, self.token]) @property def absolute_url(self) -> str: diff --git a/server/vb/ops.py b/server/vb/ops.py index b28bfef..91a2a8a 100644 --- a/server/vb/ops.py +++ b/server/vb/ops.py @@ -4,6 +4,7 @@ from django.db import transaction from server.utils.agcod import AGCODClient +from server.utils.email import send_template_email from server.utils.tokens import make_token from .models import Contest, EmailValidationLink, GiftCard, School, Student @@ -124,9 +125,20 @@ 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) + success = send_template_email( + to=email, + template_base="email/validate", + context={ + "student": student, + "contest": contest, + "email": email, + "link": link, + "title": f"Get my ${contest.amount} gift card", + }, + ) + if not success: + logger.error(f"Failed to send email validation link to {email}") return link diff --git a/server/vb/templates/email/base/body.dhtml b/server/vb/templates/email/base/body.dhtml new file mode 100644 index 0000000..79a3ae1 --- /dev/null +++ b/server/vb/templates/email/base/body.dhtml @@ -0,0 +1,272 @@ + + + + + + {{ subject }} + + + + + + + + +
  +
+ {# START CENTERED WHITE CONTAINER #} + {{ subject }} + + {# START MAIN CONTENT AREA #} + + + + {# END MAIN CONTENT AREA #} {# START CONTENT AREA FOOTER #} + + + + {# END CONTENT AREA FOOTER #} +
+ + + + +
+ + {% block message %}{% endblock %} + +
+
+ + {% block footer %} + + {% endblock footer %} + +
+ {# END CONTENT CONTAINER #} {# START GRAY AREA FOOTER #} + + + + +
+ + + + +
+ © 2024 VoterBowl, All rights reserved. +
+
+ {# END GRAY AREA FOOTER #} +
+
+ + diff --git a/server/vb/templates/email/base/body.txt b/server/vb/templates/email/base/body.txt new file mode 100644 index 0000000..a5bb51d --- /dev/null +++ b/server/vb/templates/email/base/body.txt @@ -0,0 +1,9 @@ +{% autoescape off %} +{% block message %}{% endblock %} +{% block links %}{% endblock %} +{% block footer %} +--- +The VoterBowl Team +hello@voterbowl.org +{% endblock %} +{% endautoescape %} diff --git a/server/vb/templates/email/base/button.html b/server/vb/templates/email/base/button.html new file mode 100644 index 0000000..f038d14 --- /dev/null +++ b/server/vb/templates/email/base/button.html @@ -0,0 +1,79 @@ +{# call with include url=(url OR path) title=title #} {# START EMAIL BUTTON #} + + + + + + +
+ + + + + + +
+ + {% block link_title %}{{ title }}{% endblock %} + +
+
+{# END EMAIL BUTTON #} diff --git a/server/vb/templates/email/base/center.html b/server/vb/templates/email/base/center.html new file mode 100644 index 0000000..7f2ab62 --- /dev/null +++ b/server/vb/templates/email/base/center.html @@ -0,0 +1,3 @@ + + + + +
diff --git a/server/vb/templates/email/base/center_close.html b/server/vb/templates/email/base/center_close.html new file mode 100644 index 0000000..db9d430 --- /dev/null +++ b/server/vb/templates/email/base/center_close.html @@ -0,0 +1,3 @@ +
diff --git a/server/vb/templates/email/base/p.html b/server/vb/templates/email/base/p.html new file mode 100644 index 0000000..854f060 --- /dev/null +++ b/server/vb/templates/email/base/p.html @@ -0,0 +1,2 @@ +{# opening p tag only #} +

diff --git a/server/vb/templates/email/base/p_close.html b/server/vb/templates/email/base/p_close.html new file mode 100644 index 0000000..d0c4952 --- /dev/null +++ b/server/vb/templates/email/base/p_close.html @@ -0,0 +1,2 @@ +{# closing p tag only #} +

diff --git a/server/vb/templates/email/validate/body.dhtml b/server/vb/templates/email/validate/body.dhtml new file mode 100644 index 0000000..ccdd731 --- /dev/null +++ b/server/vb/templates/email/validate/body.dhtml @@ -0,0 +1,11 @@ +{% extends "email/base/body.dhtml" %} + +{% block message %} + {% include "email/base/p.html" %} + 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. + {% include "email/base/p_close.html" %} + {% include "email/base/button.html" with url=link.relative_url title=title %} +{% endblock message %} diff --git a/server/vb/templates/email/validate/body.txt b/server/vb/templates/email/validate/body.txt new file mode 100644 index 0000000..6681a98 --- /dev/null +++ b/server/vb/templates/email/validate/body.txt @@ -0,0 +1,10 @@ +{% extends "email/base/body.txt" %} + +{% block message %}Hi {{ student.first_name }}, + +Thanks for checking your voter registration status! You're almost done. + +Click this link to get your ${{ contest.amount }} Amazon gift card: + +{{ link.absolute_url }} +{% endblock message %} diff --git a/server/vb/templates/email/validate/subject.txt b/server/vb/templates/email/validate/subject.txt new file mode 100644 index 0000000..e1227b5 --- /dev/null +++ b/server/vb/templates/email/validate/subject.txt @@ -0,0 +1 @@ +Your VoterBowl gift card awaits diff --git a/server/vb/urls.py b/server/vb/urls.py index b062425..e0ff73c 100644 --- a/server/vb/urls.py +++ b/server/vb/urls.py @@ -1,10 +1,10 @@ from django.urls import path -from .views import check, finish_check, home, school, verify_email +from .views import check, finish_check, home, school, validate_email app_name = "vb" urlpatterns = [ - path("/verify//", verify_email, name="verify_email"), + path("/v//", validate_email, name="validate_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 ed15f9b..7457fc6 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -110,7 +110,7 @@ def finish_check(request: HttpRequest, slug: str) -> HttpResponse: @require_GET -def verify_email(request: HttpRequest, slug: str, token: str) -> HttpResponse: +def validate_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) From f27f9b8e72e0d4237e4091d2cc1f2d782759ebc5 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 19 Apr 2024 11:01:53 -0700 Subject: [PATCH 13/19] Send gift card claim code in email, too. --- server/vb/ops.py | 33 ++++++++++++--- server/vb/templates/email/code/body.dhtml | 16 ++++++++ server/vb/templates/email/code/body.txt | 9 ++++ server/vb/templates/email/code/subject.txt | 1 + server/vb/templates/email/validate/body.dhtml | 2 +- server/vb/views.py | 41 ++++++++++++++++--- 6 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 server/vb/templates/email/code/body.dhtml create mode 100644 server/vb/templates/email/code/body.txt create mode 100644 server/vb/templates/email/code/subject.txt diff --git a/server/vb/ops.py b/server/vb/ops.py index 91a2a8a..56a2e00 100644 --- a/server/vb/ops.py +++ b/server/vb/ops.py @@ -61,7 +61,7 @@ def _get_claim_code(gift_card: GiftCard) -> str: def get_or_issue_gift_card( student: Student, contest: Contest, when: datetime.datetime | None = None -) -> tuple[GiftCard, str]: +) -> tuple[GiftCard, str, bool]: """ Issue a gift card to a student for a contest. @@ -72,8 +72,8 @@ def get_or_issue_gift_card( raise a GiftCardPreconditionError. If another error occurs, raise an arbitrary exception. - Returns a tuple of the gift card and, if the gift card was issued, - the claim code. + Returns a tuple of the gift card, gift code, and True if the gift card + was newly issued. """ # Precondition: student must have a validated email address. if not student.is_validated: @@ -95,13 +95,14 @@ def get_or_issue_gift_card( if gift_card is not None: claim_code = _get_claim_code(gift_card) - return gift_card, claim_code + return gift_card, claim_code, False # Precondition: the contest must be ongoing to truly issue a gift card. if not contest.is_ongoing(when): raise GiftCardPreconditionError(f"Contest '{contest.name}' is not ongoing") - return _issue_gift_card(student, contest) + gift_card, claim_code = _issue_gift_card(student, contest) + return gift_card, claim_code, True def get_or_create_student( @@ -136,9 +137,29 @@ def send_validation_link_email( "contest": contest, "email": email, "link": link, - "title": f"Get my ${contest.amount} gift card", + "button_text": f"Get my ${contest.amount} gift card", }, ) if not success: logger.error(f"Failed to send email validation link to {email}") return link + + +def send_gift_card_email( + student: Student, + gift_card: GiftCard, + claim_code: str, + email: str, +) -> None: + """Send a gift card email to a student.""" + success = send_template_email( + to=email, + template_base="email/code", + context={ + "student": student, + "gift_card": gift_card, + "claim_code": claim_code, + }, + ) + if not success: + logger.error(f"Failed to send gift card email to {email}") diff --git a/server/vb/templates/email/code/body.dhtml b/server/vb/templates/email/code/body.dhtml new file mode 100644 index 0000000..b480ebd --- /dev/null +++ b/server/vb/templates/email/code/body.dhtml @@ -0,0 +1,16 @@ +{% extends "email/base/body.dhtml" %} + +{% block message %} + {% include "email/base/p.html" %} + Hi {{ student.first_name }}, + {% include "email/base/p_close.html" %} + {% include "email/base/p.html" %} + Your ${{ gift_card.amount }} Amazon gift code is: {{ gift_card.code }} + {% include "email/base/p_close.html" %} + {% include "email/base/p.html" %} + Copy and paste it into Amazon's gift card redemption page to claim your gift. + {% include "email/base/p_close.html" %} + {% include "email/base/p.html" %} + And: be sure to tell your friends at {{ school.short_name }} to visit VoterBowl, too! + {% include "email/base/p_close.html" %} +{% endblock message %} diff --git a/server/vb/templates/email/code/body.txt b/server/vb/templates/email/code/body.txt new file mode 100644 index 0000000..4ee3347 --- /dev/null +++ b/server/vb/templates/email/code/body.txt @@ -0,0 +1,9 @@ +{% extends "email/base/body.txt" %} + +{% block message %}Hi {{ student.first_name }}, +Your ${{ gift_card.amount }} Amazon gift code is: {{ gift_card.code }} + +Copy and paste it into Amazon's gift card redemption page to claim your gift. + +And: be sure to tell your friends at {{ school.short_name }} to visit VoterBowl, too! +{% endblock message %} diff --git a/server/vb/templates/email/code/subject.txt b/server/vb/templates/email/code/subject.txt new file mode 100644 index 0000000..6735f53 --- /dev/null +++ b/server/vb/templates/email/code/subject.txt @@ -0,0 +1 @@ +Your ${{ gift_card.amount }} Amazon gift card diff --git a/server/vb/templates/email/validate/body.dhtml b/server/vb/templates/email/validate/body.dhtml index ccdd731..71ddf11 100644 --- a/server/vb/templates/email/validate/body.dhtml +++ b/server/vb/templates/email/validate/body.dhtml @@ -7,5 +7,5 @@ {% include "email/base/p.html" %} Thanks for checking your voter registration status! Click this button to get your gift card. {% include "email/base/p_close.html" %} - {% include "email/base/button.html" with url=link.relative_url title=title %} + {% include "email/base/button.html" with url=link.relative_url title=button_text %} {% endblock message %} diff --git a/server/vb/views.py b/server/vb/views.py index 7457fc6..1a304c9 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -10,6 +10,7 @@ from .ops import ( get_or_create_student, get_or_issue_gift_card, + send_gift_card_email, send_validation_link_email, ) @@ -22,7 +23,17 @@ def home(request: HttpRequest) -> HttpResponse: @require_GET def school(request: HttpRequest, slug: str) -> HttpResponse: - """Render a school page.""" + """ + Render a school landing page. + + Show details about the current contest, if there is one. + + If not, show details about the most recently completed contest, + if there is one. + + Otherwise, show generic text encouraging the visitor to check their + voter registration anyway. + """ school = get_object_or_404(School, slug=slug) current_contest = school.contests.current() return render( @@ -32,7 +43,11 @@ def school(request: HttpRequest, slug: str) -> HttpResponse: @require_GET def check(request: HttpRequest, slug: str) -> HttpResponse: - """Render a school-specific check registration page.""" + """ + Render a school-specific 'check voter registration' form page. + + This does something useful whether or not the school has a current contest. + """ school = get_object_or_404(School, slug=slug) current_contest = school.contests.current() return render( @@ -74,7 +89,14 @@ def clean_email(self): @require_POST @csrf_exempt # CONSIDER: maybe use Django's CSRF protection even here? def finish_check(request: HttpRequest, slug: str) -> HttpResponse: - """Handle a check registration form submission.""" + """ + View that is POSTed to when a student has completed a registration check. + + There may or may not be a current contest associated with the check. + + In addition, while we know the student's email ends with *.edu, we do not + yet know if it is a valid email address for the school. + """ school = get_object_or_404(School, slug=slug) current_contest = school.contests.current() if not current_contest: @@ -111,7 +133,14 @@ def finish_check(request: HttpRequest, slug: str) -> HttpResponse: @require_GET def validate_email(request: HttpRequest, slug: str, token: str) -> HttpResponse: - """Handle a student email validation link.""" + """ + View visited when a user clicks on a validation link in their email. + + There may or may not be a current contest associated with the validation. + + If the student reaches this point, we know they have a valid email that + matches the school in question. + """ link = get_object_or_404(EmailValidationLink, token=token) school = get_object_or_404(School, slug=slug) if link.school != school: @@ -119,7 +148,9 @@ def validate_email(request: HttpRequest, slug: str, token: str) -> HttpResponse: # It's money time! link.consume() - gift_card, claim_code = get_or_issue_gift_card(link.student, link.contest) + gift_card, claim_code, created = get_or_issue_gift_card(link.student, link.contest) + if created: + send_gift_card_email(link.student, gift_card, claim_code, link.email) return render( request, From b98388b38c693db992085fb89d4345eb024082f4 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 19 Apr 2024 11:42:41 -0700 Subject: [PATCH 14/19] Handle mismatched .edu registrations. --- server/vb/templates/check.dhtml | 256 +++++++++++++------------ server/vb/templates/fail_check.dhtml | 28 +++ server/vb/templates/finish_check.dhtml | 7 + server/vb/views.py | 16 +- 4 files changed, 179 insertions(+), 128 deletions(-) create mode 100644 server/vb/templates/fail_check.dhtml diff --git a/server/vb/templates/check.dhtml b/server/vb/templates/check.dhtml index a20c9be..79124e6 100644 --- a/server/vb/templates/check.dhtml +++ b/server/vb/templates/check.dhtml @@ -2,158 +2,160 @@ {% load static %} {% block title %} -Voter Bowl x {{ school.short_name }} + Voter Bowl x {{ school.short_name }} {% endblock title %} {% block head_extras %} - - + + {% endblock head_extras %} {% block body %} - -
+
+ -
- - -
-
- {{ school.short_name }} {{ school.mascot }} logo - {% if current_contest %} - {% include "components/countdown.dhtml" with contest=current_contest %} - {% endif %} + })(me()); + + (function(self) { + /** + * Finalize a verify and, possibly, mint a new gift card if all is well. + * + * @param {string} firstName + * @param {string} lastName + * @param {string} email + */ + const finishVerify = (firstName, lastName, email) => { + htmx.ajax("POST", "./finish/", { + target: self.querySelector(".urgency"), + values: { + first_name: firstName, + last_name: lastName, + email: email + } + }); + }; + + window.addEventListener('VoteAmericaEvent', (event) => { + const { + data + } = event.detail; + if (data?.tool === "verify" && data?.event === "action-finish") { + setTimeout(() => { + // scroll entire window back to top, smoothly + window.scrollTo({ + top: 0, + behavior: 'smooth' + }); + finishVerify(data.first_name, data.last_name, data.email); + }, 500); + } + }); + })(me()); + +
+
+ {{ school.short_name }} {{ school.mascot }} logo + {% if current_contest %} + {% include "components/countdown.dhtml" with contest=current_contest %} + {% endif %} +
+
+
+
+
+
+ +
-
-
-
-
-
- -
-
-{% endblock body %} \ No newline at end of file +{% endblock body %} diff --git a/server/vb/templates/fail_check.dhtml b/server/vb/templates/fail_check.dhtml new file mode 100644 index 0000000..7f02c30 --- /dev/null +++ b/server/vb/templates/fail_check.dhtml @@ -0,0 +1,28 @@ +{{ school.short_name }} {{ school.mascot }} logo +

+ + We could not use your email. Please use your {{ school.short_name }} student email. +

\ No newline at end of file diff --git a/server/vb/templates/finish_check.dhtml b/server/vb/templates/finish_check.dhtml index 816386c..2e4eb5c 100644 --- a/server/vb/templates/finish_check.dhtml +++ b/server/vb/templates/finish_check.dhtml @@ -1,5 +1,12 @@ {{ school.short_name }} {{ school.mascot }} logo

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

diff --git a/server/vb/views.py b/server/vb/views.py index 1a304c9..1a8a530 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -103,7 +103,21 @@ def finish_check(request: HttpRequest, slug: str) -> HttpResponse: raise ValueError("No active contest TODO") form = FinishCheckForm(request.POST, school=school) if not form.is_valid(): - raise PermissionDenied("Invalid form") + # Check if `email` is the only field that failed. + if "email" in form.errors and len(form.errors) == 1: + return render( + request, + "fail_check.dhtml", + { + "school": school, + "first_name": form.cleaned_data["first_name"], + "last_name": form.cleaned_data["last_name"], + "current_contest": current_contest, + }, + ) + + # Nope, it wasn't just the email field. Fail hard for now. + raise PermissionDenied("Invalid") email = form.cleaned_data["email"] # Create a new student if necessary. From 402ea4510b3ff054e1a51185a11a10ef710515bc Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 19 Apr 2024 12:19:58 -0700 Subject: [PATCH 15/19] Thread through no-constest behavior. --- server/vb/admin.py | 8 +-- server/vb/migrations/0001_initial.py | 21 +++++-- server/vb/migrations/0002_email_validation.py | 40 ------------- server/vb/models.py | 56 ++++++++---------- server/vb/ops.py | 14 ++++- server/vb/templates/check.dhtml | 2 + server/vb/templates/email/validate/body.dhtml | 2 +- server/vb/templates/email/validate/body.txt | 2 +- .../vb/templates/email/validate/subject.txt | 2 +- server/vb/templates/finish_check.dhtml | 7 ++- server/vb/templates/school.dhtml | 8 ++- server/vb/templates/verify_email.dhtml | 22 ++++--- server/vb/views.py | 59 +++++++++++-------- 13 files changed, 121 insertions(+), 122 deletions(-) delete mode 100644 server/vb/migrations/0002_email_validation.py 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 @@

Welcome to the Voter Bowl

{% if current_contest %}

{{ 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 %}
{% include "components/button.dhtml" with text="Check my voter status" href="./check/" bg_color=school.logo.action_color color=school.logo.action_text_color %} diff --git a/server/vb/templates/verify_email.dhtml b/server/vb/templates/verify_email.dhtml index 9b30d8f..94f16a3 100644 --- a/server/vb/templates/verify_email.dhtml +++ b/server/vb/templates/verify_email.dhtml @@ -127,15 +127,19 @@
{{ 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. -

+ {% if gift_card and claim_code %} +

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. +

+ {% else %} +

Thanks for verifying your email address, and for checking your registration status. Happy voting in 2024!

+ {% endif %}
diff --git a/server/vb/views.py b/server/vb/views.py index 1a8a530..f41f4e2 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -36,8 +36,15 @@ def school(request: HttpRequest, slug: str) -> HttpResponse: """ school = get_object_or_404(School, slug=slug) current_contest = school.contests.current() + past_contest = school.contests.most_recent_past() return render( - request, "school.dhtml", {"school": school, "current_contest": current_contest} + request, + "school.dhtml", + { + "school": school, + "current_contest": current_contest, + "past_contest": past_contest, + }, ) @@ -85,6 +92,10 @@ def clean_email(self): self.cleaned_data["hash"] = self._school.hash_email(email) return email + def has_only_email_error(self): + """Check if the only error is in the email field.""" + return "email" in self.errors and len(self.errors) == 1 + @require_POST @csrf_exempt # CONSIDER: maybe use Django's CSRF protection even here? @@ -99,25 +110,20 @@ def finish_check(request: HttpRequest, slug: str) -> HttpResponse: """ school = get_object_or_404(School, slug=slug) current_contest = school.contests.current() - if not current_contest: - raise ValueError("No active contest TODO") form = FinishCheckForm(request.POST, school=school) if not form.is_valid(): - # Check if `email` is the only field that failed. - if "email" in form.errors and len(form.errors) == 1: - return render( - request, - "fail_check.dhtml", - { - "school": school, - "first_name": form.cleaned_data["first_name"], - "last_name": form.cleaned_data["last_name"], - "current_contest": current_contest, - }, - ) - - # Nope, it wasn't just the email field. Fail hard for now. - raise PermissionDenied("Invalid") + if not form.has_only_email_error(): + raise PermissionDenied("Invalid") + return render( + request, + "fail_check.dhtml", + { + "school": school, + "first_name": form.cleaned_data["first_name"], + "last_name": form.cleaned_data["last_name"], + "current_contest": current_contest, + }, + ) email = form.cleaned_data["email"] # Create a new student if necessary. @@ -132,7 +138,7 @@ def finish_check(request: HttpRequest, slug: str) -> HttpResponse: # 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) + send_validation_link_email(student, school, current_contest, email) return render( request, @@ -160,11 +166,18 @@ def validate_email(request: HttpRequest, slug: str, token: str) -> HttpResponse: if link.school != school: raise PermissionDenied("Invalid email validation link URL") - # It's money time! + # The student is validated now! link.consume() - gift_card, claim_code, created = get_or_issue_gift_card(link.student, link.contest) - if created: - send_gift_card_email(link.student, gift_card, claim_code, link.email) + + # If there's a contest associated with the validation, get a gift card. + gift_card = None + claim_code = None + if link.contest is not None: + gift_card, claim_code, created = get_or_issue_gift_card( + link.student, link.contest + ) + if created: + send_gift_card_email(link.student, gift_card, claim_code, link.email) return render( request, From 9ba4a735175a2e53e7a2adc2630d175c39508014 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 19 Apr 2024 14:15:32 -0700 Subject: [PATCH 16/19] Copy edits --- server/vb/migrations/0001_initial.py | 2 +- server/vb/templates/check.dhtml | 8 +++++++- server/vb/templates/finish_check.dhtml | 2 +- server/vb/templates/school.dhtml | 10 ++++------ server/vb/templates/verify_email.dhtml | 8 +++++++- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/server/vb/migrations/0001_initial.py b/server/vb/migrations/0001_initial.py index 0504102..6fbd35c 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-19 18:59 +# Generated by Django 5.0.3 on 2024-04-19 20:51 import django.core.validators import django.db.models.deletion diff --git a/server/vb/templates/check.dhtml b/server/vb/templates/check.dhtml index 7224805..3a2de75 100644 --- a/server/vb/templates/check.dhtml +++ b/server/vb/templates/check.dhtml @@ -92,6 +92,10 @@ right: 0; overflow: hidden; } + + me .separate { + padding-left: 1rem; + }