Skip to content

Commit 7e57a17

Browse files
committed
feat(preferences): added personal email verification
1 parent 5fa3ea2 commit 7e57a17

File tree

13 files changed

+202
-4
lines changed

13 files changed

+202
-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 specified expire time and should be deleted.
18+
def is_expired(self):
19+
return now() - self.date_created >= timedelta(hours=settings.UNVERIFIED_EMAIL_EXPIRE_HOURS)

intranet/apps/preferences/tasks.py

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

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: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
from django.contrib.auth import get_user_model
77
from django.contrib.auth.decorators import login_required
88
from django.shortcuts import redirect, render
9+
from django.urls import reverse
910

1011
from ..auth.decorators import eighth_admin_required
1112
from ..bus.models import Route
13+
from ..notifications.tasks import email_send_task
1214
from ..users.models import Email
1315
from .forms import BusRouteForm, DarkModeForm, EmailFormset, NotificationOptionsForm, PreferredPictureForm, PrivacyOptionsForm
16+
from .models import UnverifiedEmail
1417

1518
# from .forms import (BusRouteForm, DarkModeForm, EmailFormset, NotificationOptionsForm, PhoneFormset, PreferredPictureForm, PrivacyOptionsForm,
1619
# WebsiteFormset)
@@ -52,6 +55,20 @@ def get_personal_info(user):
5255
return personal_info, num_fields
5356

5457

58+
def send_verification_email(request, user, email):
59+
email_link = UnverifiedEmail(user=user, email=email)
60+
email_link.save()
61+
62+
verification_link = request.build_absolute_uri(reverse("verify_email", args=[email_link.verification_token]))
63+
base_url = request.build_absolute_uri(reverse("index"))
64+
data = {"verification_link": verification_link, "base_url": base_url}
65+
headers = {"From": "Ion <[email protected]>"}
66+
67+
email_send_task.delay(
68+
"preferences/email/verify_email.txt", "preferences/email/verify_email.html", data, "Email Verification", [email.address], headers
69+
)
70+
71+
5572
def save_personal_info(request, user):
5673
# phone_formset = PhoneFormset(request.POST, instance=user, prefix="pf")
5774
phone_formset = None
@@ -68,7 +85,24 @@ def save_personal_info(request, user):
6885
# else:
6986
# errors.append("Could not set phone numbers.")
7087
if email_formset.is_valid():
71-
email_formset.save()
88+
new_emails = email_formset.save(commit=False)
89+
90+
# Manually handle saving the formset so we can flag new emails as unverified.
91+
for email in new_emails:
92+
if email._state.adding:
93+
email.verified = False
94+
email.save()
95+
send_verification_email(request, user, email)
96+
messages.success(
97+
request,
98+
f"Successfully sent verification email to '{email.address}'. The link will expire in {settings.UNVERIFIED_EMAIL_EXPIRE_HOURS} hours.",
99+
)
100+
101+
for deleted_email in email_formset.deleted_objects:
102+
try:
103+
deleted_email.delete()
104+
except deleted_email.DoesNotExist:
105+
pass
72106
else:
73107
for error in email_formset.errors:
74108
if isinstance(error.get("address"), list):
@@ -207,6 +241,13 @@ def save_notification_options(request, user):
207241
if field in notification_options and notification_options[field] == fields[field]:
208242
pass
209243
else:
244+
# Users should only be able to set verified emails as their primary email.
245+
if field == "primary_email" and fields[field] is not None:
246+
email = Email.objects.filter(user=user, address=fields[field]).first()
247+
if not email.verified:
248+
messages.error(request, "You may only set verified emails as your primary email.")
249+
continue
250+
210251
setattr(user, field, fields[field])
211252
user.save()
212253
try:
@@ -290,6 +331,28 @@ def save_dark_mode_settings(request, user):
290331
return dark_mode_form
291332

292333

334+
@login_required
335+
def verify_email_view(request, email_uuid):
336+
""" "Verify the UUID associated with the unverified email."""
337+
user = request.user
338+
339+
unverified_email = UnverifiedEmail.objects.filter(verification_token=email_uuid, user=user).first()
340+
341+
# If the uuid isn't found or link is expired, return a error message.
342+
if unverified_email is None or unverified_email.is_expired():
343+
messages.error(request, "Could not verify email, the link was either expired or invalid.")
344+
return redirect("preferences")
345+
346+
verified_mail = unverified_email.email
347+
verified_mail.verified = True
348+
349+
verified_mail.save()
350+
unverified_email.delete()
351+
352+
messages.success(request, f"Successfully verified '{verified_mail.address}'. You can add it as a primary email now.")
353+
return redirect("preferences")
354+
355+
293356
@login_required
294357
def preferences_view(request):
295358
"""View and process updates to the preferences page."""
@@ -331,6 +394,13 @@ def preferences_view(request):
331394
email_formset = EmailFormset(instance=user, prefix="ef")
332395
# website_formset = WebsiteFormset(instance=user, prefix="wf")
333396

397+
# Flag emails as verified or unverified for templating.
398+
for form in email_formset:
399+
if form.instance.pk:
400+
form.verified = form.instance.verified
401+
else:
402+
form.verified = None
403+
334404
if user.is_student:
335405
preferred_pic = get_preferred_pic(user)
336406
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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,9 @@ def get_log(name): # pylint: disable=redefined-outer-name; 'name' is used as th
919919
# Age (in days) of a lost and found entry until it is hidden
920920
LOSTFOUND_EXPIRATION = 180
921921

922+
# How many hours till a verification email expires.
923+
UNVERIFIED_EMAIL_EXPIRE_HOURS = 12
924+
922925
# Substrings of paths to not log in the Ion access logs
923926
NONLOGGABLE_PATH_BEGINNINGS = ["/static"]
924927
NONLOGGABLE_PATH_ENDINGS = [".png", ".jpg", ".jpeg", ".gif", ".css", ".js", ".ico", "jsi18n/"]
@@ -960,6 +963,11 @@ def get_log(name): # pylint: disable=redefined-outer-name; 'name' is used as th
960963
"schedule": celery.schedules.crontab(day_of_month=1, hour=1),
961964
"args": (),
962965
},
966+
"delete-expired-email-links": {
967+
"task": "intranet.apps.preferences.tasks.delete_expired_emails",
968+
"schedule": celery.schedules.crontab(hour=0, minute=0),
969+
"args": (),
970+
}
963971
}
964972

965973
MAINTENANCE_MODE = False

0 commit comments

Comments
 (0)