Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/source/reference_index/apps/preferences.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ preferences

fields
forms
models
tasks
tests
urls
views
14 changes: 9 additions & 5 deletions intranet/apps/emailfwd/forms.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from django import forms

from .models import SeniorEmailForward
from ..users.models import Email


class SeniorEmailForwardForm(forms.ModelForm):
class Meta:
model = SeniorEmailForward
fields = ["email"]
class SeniorEmailForwardForm(forms.Form):
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs)

label = "Email Forward"
self.fields["email"] = forms.ModelChoiceField(
queryset=Email.objects.filter(user=user, verified=True), required=False, label=label, disabled=(user.emails.all().count() == 0)
)
23 changes: 17 additions & 6 deletions intranet/apps/emailfwd/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from ...test.ion_test import IonTestCase
from ...utils.date import get_senior_graduation_year
from ..users.models import Email
from .models import SeniorEmailForward


Expand All @@ -25,14 +26,23 @@ def test_email_fwd(self):
self.assertEqual(response.status_code, 200)

# Now, test setting an email
response = self.client.post(reverse("senior_emailfwd"), data={"email": "[email protected]"}, follow=True)
email = Email.objects.get_or_create(user=user, address="[email protected]", verified=True)[0]
response = self.client.post(reverse("senior_emailfwd"), data={"email": email.id}, follow=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(1, SeniorEmailForward.objects.filter(user=user, email="[email protected]").count())
self.assertEqual(1, SeniorEmailForward.objects.filter(user=user).count())
self.assertIn("Successfully added forwarding address.", list(map(str, list(response.context["messages"]))))

# Test invalid email
response = self.client.post(reverse("senior_emailfwd"), data={"email": "nonexistenttjhsst.edu"}, follow=True)
# Test removing an email
response = self.client.post(reverse("senior_emailfwd"), data={"email": ""}, follow=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(1, SeniorEmailForward.objects.filter(user=user, email="[email protected]").count())
self.assertEqual(0, SeniorEmailForward.objects.filter(user=user).count())
self.assertIn("Successfully cleared email forward.", list(map(str, list(response.context["messages"]))))

# Test setting an unverified email
email = Email.objects.get_or_create(user=user, address="[email protected]", verified=False)[0]
response = self.client.post(reverse("senior_emailfwd"), data={"email": email.id}, follow=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(0, SeniorEmailForward.objects.filter(user=user).count())
self.assertIn("Error adding forwarding address.", list(map(str, list(response.context["messages"]))))


Expand All @@ -44,7 +54,8 @@ def test_getting_forwards(self):
self.login()

# Set the email
response = self.client.post(reverse("senior_emailfwd"), data={"email": "[email protected]"}, follow=True)
email = Email.objects.get_or_create(user=user, address="[email protected]")[0]
response = self.client.post(reverse("senior_emailfwd"), data={"email": email.id}, follow=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(1, SeniorEmailForward.objects.filter(user=user, email="[email protected]").count())

Expand Down
44 changes: 32 additions & 12 deletions intranet/apps/emailfwd/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@
logger = logging.getLogger(__name__)


def add_email_forward(user, email, forward):
"""Create a new forward if no forward exists or update the existing one."""

if forward is None:
forward = SeniorEmailForward(user=user, email=email.address)
forward.save()
else:
forward.address = email.address
forward.save()

return forward


@login_required
@deny_restricted
def senior_email_forward_view(request):
Expand All @@ -24,20 +37,27 @@ def senior_email_forward_view(request):
forward = None

if request.method == "POST":
if forward:
form = SeniorEmailForwardForm(request.POST, instance=forward)
else:
form = SeniorEmailForwardForm(request.POST)
form = SeniorEmailForwardForm(request.user, request.POST)

if form.is_valid():
obj = form.save(commit=False)
obj.user = request.user
obj.save()
messages.success(request, "Successfully added forwarding address.")
return redirect("index")
email = form.cleaned_data["email"]

if email is None:
if forward is not None:
forward.delete()
forward = None
messages.success(request, "Successfully cleared email forward.")

elif email.verified:
forward = add_email_forward(request.user, email, forward)

messages.success(request, "Successfully added forwarding address.")
else:
messages.error(request, "You can only forward verified emails.")
else:
messages.error(request, "Error adding forwarding address.")
elif forward:
form = SeniorEmailForwardForm(instance=forward)

else:
form = SeniorEmailForwardForm()
form = SeniorEmailForwardForm(request.user)

return render(request, "emailfwd/senior_forward.html", {"form": form, "forward": forward})
2 changes: 1 addition & 1 deletion intranet/apps/preferences/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def flag(label, default):
if user.emails.all().count() == 0:
label = "You can set a primary email after adding emails below."
self.fields["primary_email"] = forms.ModelChoiceField(
queryset=Email.objects.filter(user=user), required=False, label=label, disabled=(user.emails.all().count() == 0)
queryset=Email.objects.filter(user=user, verified=True), required=False, label=label, disabled=(user.emails.all().count() == 0)
)


Expand Down
29 changes: 29 additions & 0 deletions intranet/apps/preferences/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 3.2.25 on 2025-05-07 01:56

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid


class Migration(migrations.Migration):

initial = True

dependencies = [
('users', '0043_email_verified'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='UnverifiedEmail',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date_created', models.DateTimeField(auto_now_add=True)),
('verification_token', models.UUIDField(default=uuid.uuid4, editable=False)),
('email', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.email')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
19 changes: 19 additions & 0 deletions intranet/apps/preferences/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import uuid
from datetime import timedelta

from django.conf import settings
from django.db import models
from django.utils.timezone import now

from ..users.models import Email


class UnverifiedEmail(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
email = models.ForeignKey(Email, on_delete=models.CASCADE)
date_created = models.DateTimeField(auto_now_add=True)
verification_token = models.UUIDField(default=uuid.uuid4, editable=False)

# Email link is expired if it's older than specified expire time and should be deleted.
def is_expired(self):
return now() - self.date_created >= timedelta(hours=settings.UNVERIFIED_EMAIL_EXPIRE_HOURS)
22 changes: 22 additions & 0 deletions intranet/apps/preferences/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from datetime import timedelta

from celery import shared_task
from celery.utils.log import get_task_logger
from django.conf import settings
from django.utils.timezone import now

from .models import UnverifiedEmail

logger = get_task_logger(__name__)


@shared_task
def delete_expired_emails():
# Unverified email links should be deleted after specified timeout.
cutoff = now() - timedelta(hours=settings.UNVERIFIED_EMAIL_EXPIRE_HOURS)
expired_emails = UnverifiedEmail.objects.filter(date_created__lt=cutoff)

emails_deleted = expired_emails.count()
expired_emails.delete()

logger.info(f"Deleted {emails_deleted} expired email links.")
6 changes: 5 additions & 1 deletion intranet/apps/preferences/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@

from . import views

urlpatterns = [path("", views.preferences_view, name="preferences"), path("/privacy", views.privacy_options_view, name="privacy_options")]
urlpatterns = [
re_path(r"^$", views.preferences_view, name="preferences"),
re_path(r"^/privacy$", views.privacy_options_view, name="privacy_options"),
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.
]
72 changes: 71 additions & 1 deletion intranet/apps/preferences/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render
from django.urls import reverse

from ..auth.decorators import eighth_admin_required
from ..bus.models import Route
from ..notifications.tasks import email_send_task
from ..users.models import Email
from .forms import BusRouteForm, DarkModeForm, EmailFormset, NotificationOptionsForm, PreferredPictureForm, PrivacyOptionsForm
from .models import UnverifiedEmail

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


def send_verification_email(request, user, email):
email_link = UnverifiedEmail(user=user, email=email)
email_link.save()

verification_link = request.build_absolute_uri(reverse("verify_email", args=[email_link.verification_token]))
base_url = request.build_absolute_uri(reverse("index"))
data = {"verification_link": verification_link, "base_url": base_url}
headers = {"From": "Ion <[email protected]>"}

email_send_task.delay(
"preferences/email/verify_email.txt", "preferences/email/verify_email.html", data, "Email Verification", [email.address], headers
)


def save_personal_info(request, user):
# phone_formset = PhoneFormset(request.POST, instance=user, prefix="pf")
phone_formset = None
Expand All @@ -68,7 +85,24 @@ def save_personal_info(request, user):
# else:
# errors.append("Could not set phone numbers.")
if email_formset.is_valid():
email_formset.save()
new_emails = email_formset.save(commit=False)

# Manually handle saving the formset so we can flag new emails as unverified.
for email in new_emails:
if email._state.adding:
email.verified = False
email.save()
send_verification_email(request, user, email)
messages.success(
request,
f"Successfully sent verification email to '{email.address}'. The link will expire in {settings.UNVERIFIED_EMAIL_EXPIRE_HOURS} hours.",
)

for deleted_email in email_formset.deleted_objects:
try:
deleted_email.delete()
except deleted_email.DoesNotExist:
pass
else:
for error in email_formset.errors:
if isinstance(error.get("address"), list):
Expand Down Expand Up @@ -207,6 +241,13 @@ def save_notification_options(request, user):
if field in notification_options and notification_options[field] == fields[field]:
pass
else:
# Users should only be able to set verified emails as their primary email.
if field == "primary_email" and fields[field] is not None:
email = Email.objects.filter(user=user, address=fields[field]).first()
if not email.verified:
messages.error(request, "You may only set verified emails as your primary email.")
continue

setattr(user, field, fields[field])
user.save()
try:
Expand Down Expand Up @@ -290,6 +331,28 @@ def save_dark_mode_settings(request, user):
return dark_mode_form


@login_required
def verify_email_view(request, email_uuid):
""" "Verify the UUID associated with the unverified email."""
user = request.user

unverified_email = UnverifiedEmail.objects.filter(verification_token=email_uuid, user=user).first()

# If the uuid isn't found or link is expired, return a error message.
if unverified_email is None or unverified_email.is_expired():
messages.error(request, "Could not verify email, the link was either expired or invalid.")
return redirect("preferences")

verified_mail = unverified_email.email
verified_mail.verified = True

verified_mail.save()
unverified_email.delete()

messages.success(request, f"Successfully verified '{verified_mail.address}'. You can add it as a primary email now.")
return redirect("preferences")


@login_required
def preferences_view(request):
"""View and process updates to the preferences page."""
Expand Down Expand Up @@ -331,6 +394,13 @@ def preferences_view(request):
email_formset = EmailFormset(instance=user, prefix="ef")
# website_formset = WebsiteFormset(instance=user, prefix="wf")

# Flag emails as verified or unverified for templating.
for form in email_formset:
if form.instance.pk:
form.verified = form.instance.verified
else:
form.verified = None

if user.is_student:
preferred_pic = get_preferred_pic(user)
bus_route = get_bus_route(user)
Expand Down
18 changes: 18 additions & 0 deletions intranet/apps/users/migrations/0043_email_verified.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.25 on 2025-05-07 01:56

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('users', '0042_user_seen_april_fools'),
]

operations = [
migrations.AddField(
model_name='email',
name='verified',
field=models.BooleanField(default=True),
),
]
1 change: 1 addition & 0 deletions intranet/apps/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,7 @@ class Email(models.Model):

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

def __str__(self):
return self.address
Expand Down
8 changes: 8 additions & 0 deletions intranet/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,9 @@ def get_log(name): # pylint: disable=redefined-outer-name; 'name' is used as th
# Age (in days) of a lost and found entry until it is hidden
LOSTFOUND_EXPIRATION = 180

# How many hours till a verification email expires.
UNVERIFIED_EMAIL_EXPIRE_HOURS = 12

# Substrings of paths to not log in the Ion access logs
NONLOGGABLE_PATH_BEGINNINGS = ["/static"]
NONLOGGABLE_PATH_ENDINGS = [".png", ".jpg", ".jpeg", ".gif", ".css", ".js", ".ico", "jsi18n/"]
Expand Down Expand Up @@ -988,6 +991,11 @@ def get_log(name): # pylint: disable=redefined-outer-name; 'name' is used as th
"schedule": celery.schedules.crontab(day_of_month=1, hour=1),
"args": (),
},
"delete-expired-email-links": {
"task": "intranet.apps.preferences.tasks.delete_expired_emails",
"schedule": celery.schedules.crontab(hour=0, minute=0),
"args": (),
}
}

MAINTENANCE_MODE = False
Expand Down
Loading
Loading