Skip to content

Commit 5799e55

Browse files
committed
feat(preferences): added personal email verification
1 parent 0ad6651 commit 5799e55

File tree

13 files changed

+231
-4
lines changed

13 files changed

+231
-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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import uuid
2+
from datetime import datetime, timedelta
3+
4+
from django.conf import settings
5+
from django.db import models
6+
7+
from ..users.models import Email
8+
9+
10+
class UnverifiedEmail(models.Model):
11+
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
12+
email = models.ForeignKey(Email, on_delete=models.CASCADE)
13+
date_created = models.DateTimeField(auto_now_add=True)
14+
verification_token = models.UUIDField(default=uuid.uuid4, editable=False)
15+
16+
# Email link is expired if it's older than 6 hours and should be deleted.
17+
def is_expired(self):
18+
return datetime.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(created_at__lt=cutoff)
17+
18+
emails_deleted = expired_emails.count()
19+
expired_emails.delete()
20+
21+
logger.info(f"Deleted {emails_deleted} unverified emails 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,15 @@
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
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,18 @@ def get_personal_info(user):
5255
return personal_info, num_fields
5356

5457

58+
def send_verification_email(user, email):
59+
email_link = UnverifiedEmail(user=user, email=email)
60+
email_link.save()
61+
62+
data = {"email_address": email.address, "verification_link": f"ion.tjhsst.edu/preferences/verify_email/{email_link.verification_token}/"}
63+
headers = {"From": "ION <[email protected]>", "Reply-To": "[email protected]"}
64+
65+
email_send_task.delay(
66+
"preferences/email/verify_email.txt", "preferences/email/verify_email.html", data, "ION Email Verification", [email.address], headers
67+
)
68+
69+
5570
def save_personal_info(request, user):
5671
# phone_formset = PhoneFormset(request.POST, instance=user, prefix="pf")
5772
phone_formset = None
@@ -68,7 +83,23 @@ def save_personal_info(request, user):
6883
# else:
6984
# errors.append("Could not set phone numbers.")
7085
if email_formset.is_valid():
71-
email_formset.save()
86+
new_emails = email_formset.save(commit=False)
87+
88+
# Manually handle saving the formset so we can flag new emails as unverified.
89+
for email in new_emails:
90+
if email._state.adding:
91+
email.verified = False
92+
email.save()
93+
send_verification_email(user, email)
94+
messages.success(
95+
request, f"Successfully sent verification email to '{email.address}'. This address will be deleted if it's not verified in 6 hours."
96+
)
97+
98+
for deleted_email in email_formset.deleted_objects:
99+
try:
100+
deleted_email.delete()
101+
except deleted_email.DoesNotExist:
102+
pass
72103
else:
73104
for error in email_formset.errors:
74105
if isinstance(error.get("address"), list):
@@ -207,6 +238,13 @@ def save_notification_options(request, user):
207238
if field in notification_options and notification_options[field] == fields[field]:
208239
pass
209240
else:
241+
# Users should only be able to set verified emails as their primary email.
242+
if field == "primary_email" and fields[field] is not None:
243+
email = Email.objects.filter(user=user, address=fields[field]).first()
244+
if not email.verified:
245+
messages.error(request, "You may only set verified emails as your primary email.")
246+
continue
247+
210248
setattr(user, field, fields[field])
211249
user.save()
212250
try:
@@ -290,6 +328,28 @@ def save_dark_mode_settings(request, user):
290328
return dark_mode_form
291329

292330

331+
@login_required
332+
def verify_email_view(request, email_uuid):
333+
""" "Verify the UUID associated with the unverified email."""
334+
user = request.user
335+
336+
unverified_email = UnverifiedEmail.objects.filter(verification_token=email_uuid, user=user).first()
337+
338+
# If the uuid isn't found, it will return the default 404 form.
339+
if unverified_email is None:
340+
raise Http404
341+
342+
verified_mail = unverified_email.email
343+
verified_mail.verified = True
344+
345+
verified_mail.save()
346+
unverified_email.delete()
347+
348+
context = {"email": verified_mail}
349+
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
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<p>
2+
Hello,
3+
<p>
4+
<p>
5+
Somone, hopefully you, has requested to add this email to their ION account.
6+
</p>
7+
<p>
8+
If this was you, you may navigate to the link below and verify this email.
9+
Otherwise, you can safely ignore this email.
10+
</p>
11+
{{ verification_link }}
12+
<p>
13+
This was an automated message sent from the TJ Intranet system.
14+
</p>
15+
----
16+
<br>
17+
TJHSST Computer Systems Lab
18+
6560 Braddock Rd., Alexandria, VA 22132
19+

0 commit comments

Comments
 (0)