Skip to content

Commit a1d0c30

Browse files
committed
feat(preferences): added personal email verification
fix(preferences): included email footers Added celery task and updated verified email template
1 parent 0ad6651 commit a1d0c30

File tree

15 files changed

+242
-4
lines changed

15 files changed

+242
-4
lines changed

docs/source/reference_index/apps/preferences.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ preferences
99

1010
fields
1111
forms
12+
models
13+
tasks
1214
tests
1315
urls
1416
views

intranet/apps/preferences/forms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def flag(label, default):
9090
if user.emails.all().count() == 0:
9191
label = "You can set a primary email after adding emails below."
9292
self.fields["primary_email"] = forms.ModelChoiceField(
93-
queryset=Email.objects.filter(user=user), required=False, label=label, disabled=(user.emails.all().count() == 0)
93+
queryset=Email.objects.filter(user=user, verified=True), required=False, label=label, disabled=(user.emails.all().count() == 0)
9494
)
9595

9696

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Generated by Django 3.2.25 on 2025-05-07 01:56
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
import uuid
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
initial = True
12+
13+
dependencies = [
14+
('users', '0043_email_verified'),
15+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16+
]
17+
18+
operations = [
19+
migrations.CreateModel(
20+
name='UnverifiedEmail',
21+
fields=[
22+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23+
('date_created', models.DateTimeField(auto_now_add=True)),
24+
('verification_token', models.UUIDField(default=uuid.uuid4, editable=False)),
25+
('email', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.email')),
26+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
27+
],
28+
),
29+
]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import uuid
2+
from datetime import timedelta
3+
4+
from django.conf import settings
5+
from django.db import models
6+
from django.utils.timezone import now
7+
8+
from ..users.models import Email
9+
10+
11+
class UnverifiedEmail(models.Model):
12+
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
13+
email = models.ForeignKey(Email, on_delete=models.CASCADE)
14+
date_created = models.DateTimeField(auto_now_add=True)
15+
verification_token = models.UUIDField(default=uuid.uuid4, editable=False)
16+
17+
# Email link is expired if it's older than 6 hours and should be deleted.
18+
def is_expired(self):
19+
return now() - self.date_created >= timedelta(hours=6)

intranet/apps/preferences/tasks.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from datetime import timedelta
2+
3+
from celery import shared_task
4+
from celery.utils.log import get_task_logger
5+
from django.utils.timezone import now
6+
7+
from .models import UnverifiedEmail
8+
9+
logger = get_task_logger(__name__)
10+
11+
12+
@shared_task
13+
def delete_expired_emails():
14+
# Unverified email links should be deleted after 6 hours.
15+
cutoff = now() - timedelta(hours=6)
16+
expired_emails = UnverifiedEmail.objects.filter(date_created__lt=cutoff)
17+
18+
emails_deleted = expired_emails.count()
19+
expired_emails.delete()
20+
21+
logger.info(f"Deleted {emails_deleted} unverified emails links that were older than 6 hours.")

intranet/apps/preferences/urls.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,8 @@
22

33
from . import views
44

5-
urlpatterns = [re_path(r"^$", views.preferences_view, name="preferences"), re_path(r"^/privacy$", views.privacy_options_view, name="privacy_options")]
5+
urlpatterns = [
6+
re_path(r"^$", views.preferences_view, name="preferences"),
7+
re_path(r"^/privacy$", views.privacy_options_view, name="privacy_options"),
8+
re_path(r"^/verify_email/(?P<email_uuid>[0-9a-fA-F-]{36})$", views.verify_email_view, name="verify_email"), # The path only accepts valid UUIDs.
9+
]

intranet/apps/preferences/views.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@
55
from django.contrib import messages
66
from django.contrib.auth import get_user_model
77
from django.contrib.auth.decorators import login_required
8+
from django.http import Http404
89
from django.shortcuts import redirect, render
10+
from django.urls import reverse
911

1012
from ..auth.decorators import eighth_admin_required
1113
from ..bus.models import Route
14+
from ..notifications.tasks import email_send_task
1215
from ..users.models import Email
1316
from .forms import BusRouteForm, DarkModeForm, EmailFormset, NotificationOptionsForm, PreferredPictureForm, PrivacyOptionsForm
17+
from .models import UnverifiedEmail
1418

1519
# from .forms import (BusRouteForm, DarkModeForm, EmailFormset, NotificationOptionsForm, PhoneFormset, PreferredPictureForm, PrivacyOptionsForm,
1620
# WebsiteFormset)
@@ -52,6 +56,20 @@ def get_personal_info(user):
5256
return personal_info, num_fields
5357

5458

59+
def send_verification_email(request, user, email):
60+
email_link = UnverifiedEmail(user=user, email=email)
61+
email_link.save()
62+
63+
verification_link = request.build_absolute_uri(reverse("verify_email", args=[email_link.verification_token]))
64+
base_url = request.build_absolute_uri(reverse("index"))
65+
data = {"verification_link": verification_link, "base_url": base_url}
66+
headers = {"From": "ION <no-reply@tjhsst.edu>"}
67+
68+
email_send_task.delay(
69+
"preferences/email/verify_email.txt", "preferences/email/verify_email.html", data, "ION Email Verification", [email.address], headers
70+
)
71+
72+
5573
def save_personal_info(request, user):
5674
# phone_formset = PhoneFormset(request.POST, instance=user, prefix="pf")
5775
phone_formset = None
@@ -68,7 +86,21 @@ def save_personal_info(request, user):
6886
# else:
6987
# errors.append("Could not set phone numbers.")
7088
if email_formset.is_valid():
71-
email_formset.save()
89+
new_emails = email_formset.save(commit=False)
90+
91+
# Manually handle saving the formset so we can flag new emails as unverified.
92+
for email in new_emails:
93+
if email._state.adding:
94+
email.verified = False
95+
email.save()
96+
send_verification_email(request, user, email)
97+
messages.success(request, f"Successfully sent verification email to '{email.address}'. The link will expire in 6 hours.")
98+
99+
for deleted_email in email_formset.deleted_objects:
100+
try:
101+
deleted_email.delete()
102+
except deleted_email.DoesNotExist:
103+
pass
72104
else:
73105
for error in email_formset.errors:
74106
if isinstance(error.get("address"), list):
@@ -207,6 +239,13 @@ def save_notification_options(request, user):
207239
if field in notification_options and notification_options[field] == fields[field]:
208240
pass
209241
else:
242+
# Users should only be able to set verified emails as their primary email.
243+
if field == "primary_email" and fields[field] is not None:
244+
email = Email.objects.filter(user=user, address=fields[field]).first()
245+
if not email.verified:
246+
messages.error(request, "You may only set verified emails as your primary email.")
247+
continue
248+
210249
setattr(user, field, fields[field])
211250
user.save()
212251
try:
@@ -290,6 +329,27 @@ def save_dark_mode_settings(request, user):
290329
return dark_mode_form
291330

292331

332+
@login_required
333+
def verify_email_view(request, email_uuid):
334+
""" "Verify the UUID associated with the unverified email."""
335+
user = request.user
336+
337+
unverified_email = UnverifiedEmail.objects.filter(verification_token=email_uuid, user=user).first()
338+
339+
# If the uuid isn't found or link is expired, it will return the default 404 form.
340+
if unverified_email is None or unverified_email.is_expired():
341+
raise Http404
342+
343+
verified_mail = unverified_email.email
344+
verified_mail.verified = True
345+
346+
verified_mail.save()
347+
unverified_email.delete()
348+
349+
context = {"email_address": verified_mail.address}
350+
return render(request, "preferences/verify_email.html", context)
351+
352+
293353
@login_required
294354
def preferences_view(request):
295355
"""View and process updates to the preferences page."""
@@ -331,6 +391,13 @@ def preferences_view(request):
331391
email_formset = EmailFormset(instance=user, prefix="ef")
332392
# website_formset = WebsiteFormset(instance=user, prefix="wf")
333393

394+
# Flag emails as verified or unverified for templating.
395+
for form in email_formset:
396+
if form.instance.pk:
397+
form.verified = form.instance.verified
398+
else:
399+
form.verified = None
400+
334401
if user.is_student:
335402
preferred_pic = get_preferred_pic(user)
336403
bus_route = get_bus_route(user)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 3.2.25 on 2025-05-07 01:56
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('users', '0042_user_seen_april_fools'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='email',
15+
name='verified',
16+
field=models.BooleanField(default=True),
17+
),
18+
]

intranet/apps/users/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,6 +1293,7 @@ class Email(models.Model):
12931293

12941294
address = models.EmailField()
12951295
user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="emails", on_delete=models.CASCADE)
1296+
verified = models.BooleanField(default=True)
12961297

12971298
def __str__(self):
12981299
return self.address

intranet/settings/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,11 @@ def get_log(name): # pylint: disable=redefined-outer-name; 'name' is used as th
946946
"schedule": celery.schedules.crontab(day_of_month=3, hour=1),
947947
"args": (),
948948
},
949+
"delete-expired-email-links": {
950+
"task": "intranet.apps.preferences.tasks.delete_expired_emails",
951+
"schedule": celery.schedules.crontab(hour=0, minute=0),
952+
"args": (),
953+
}
949954
}
950955

951956
MAINTENANCE_MODE = False

0 commit comments

Comments
 (0)