From 9650c7d0abdba76205715468f05415948608e10c Mon Sep 17 00:00:00 2001 From: Dean Menezes Date: Sun, 1 Mar 2026 15:24:59 -0600 Subject: [PATCH 01/14] feat: add user timezone setting with auto-detection Add timezone field to UserProfile that allows users to view all timestamps in their local timezone: - CharField with blank default (uses server time America/New_York when blank) - JavaScript auto-detection using Intl API to prefill detected timezone - TimezoneMiddleware activates user's timezone per-request using zoneinfo - All existing |date template filters automatically respect the activated timezone --- core/middleware.py | 21 ++++++++++++++++++++ core/migrations/0064_userprofile_timezone.py | 18 +++++++++++++++++ core/models.py | 7 +++++++ core/templates/core/userprofile_form.html | 16 +++++++++++++++ core/views.py | 2 ++ otisweb/settings.py | 1 + 6 files changed, 65 insertions(+) create mode 100644 core/migrations/0064_userprofile_timezone.py diff --git a/core/middleware.py b/core/middleware.py index e9587775f..cc0da4fd5 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -1,5 +1,7 @@ from typing import Callable +import zoneinfo + from django.core.cache import cache from django.http.request import HttpRequest from django.http.response import HttpResponse @@ -25,3 +27,22 @@ def __call__(self, request: HttpRequest): up.last_seen = timezone.now() up.save(update_fields=("last_seen",)) return response + + +class TimezoneMiddleware: + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): + self.get_response = get_response + + def __call__(self, request: HttpRequest): + if request.user.is_authenticated: + user_timezone = request.user.profile.timezone + if user_timezone: + try: + timezone.activate(zoneinfo.ZoneInfo(user_timezone)) + except zoneinfo.ZoneInfoNotFoundError: + timezone.deactivate() + else: + timezone.deactivate() + response = self.get_response(request) + timezone.deactivate() + return response diff --git a/core/migrations/0064_userprofile_timezone.py b/core/migrations/0064_userprofile_timezone.py new file mode 100644 index 000000000..811e76463 --- /dev/null +++ b/core/migrations/0064_userprofile_timezone.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-03-01 21:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0063_alter_userprofile_dynamic_progress_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='timezone', + field=models.CharField(blank=True, default='', help_text='Your local time zone for displaying timestamps. Leave blank to use server time (America/New_York).', max_length=63, verbose_name='Time zone'), + ), + ] diff --git a/core/models.py b/core/models.py index 948c7b342..c065b7709 100644 --- a/core/models.py +++ b/core/models.py @@ -298,6 +298,13 @@ class UserProfile(models.Model): help_text="Hide all hints from the problem archive (ARCH).", default=False, ) + timezone = models.CharField( + max_length=63, + blank=True, + default="", + verbose_name="Time zone", + help_text="Your local time zone for displaying timestamps. Leave blank to use server time (America/New_York).", + ) email_on_announcement = models.BooleanField( verbose_name="Receive emails for announcements", diff --git a/core/templates/core/userprofile_form.html b/core/templates/core/userprofile_form.html index e3f912508..9548eb559 100644 --- a/core/templates/core/userprofile_form.html +++ b/core/templates/core/userprofile_form.html @@ -35,4 +35,20 @@

Advanced settings

+ {% endblock layout-content %} diff --git a/core/views.py b/core/views.py index d20e38e11..86441eb89 100644 --- a/core/views.py +++ b/core/views.py @@ -304,6 +304,7 @@ class UserProfileUpdateView( "show_portal_instructions", "show_unit_petitions", "disable_hints", + "timezone", "use_twemoji", ) success_url = reverse_lazy("profile") @@ -332,6 +333,7 @@ def get_context_data(self, **kwargs): form["show_portal_instructions"], form["show_unit_petitions"], form["disable_hints"], + form["timezone"], form["use_twemoji"], ) diff --git a/otisweb/settings.py b/otisweb/settings.py index 597c117c3..4b46280d8 100644 --- a/otisweb/settings.py +++ b/otisweb/settings.py @@ -128,6 +128,7 @@ "allauth.account.middleware.AccountMiddleware", "hijack.middleware.HijackUserMiddleware", "core.middleware.LastSeenMiddleware", + "core.middleware.TimezoneMiddleware", ] ROOT_URLCONF = "otisweb.urls" From a665f0be65cdde2c7fa10539703911cbb5c5fe2b Mon Sep 17 00:00:00 2001 From: Dean Menezes Date: Sun, 1 Mar 2026 15:26:35 -0600 Subject: [PATCH 02/14] style: apply ruff and djlint formatting --- core/middleware.py | 3 +-- core/migrations/0064_userprofile_timezone.py | 15 +++++++---- core/templates/core/userprofile_form.html | 26 ++++++++++---------- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/core/middleware.py b/core/middleware.py index cc0da4fd5..4034a5182 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -1,6 +1,5 @@ -from typing import Callable - import zoneinfo +from typing import Callable from django.core.cache import cache from django.http.request import HttpRequest diff --git a/core/migrations/0064_userprofile_timezone.py b/core/migrations/0064_userprofile_timezone.py index 811e76463..6cc92a4c9 100644 --- a/core/migrations/0064_userprofile_timezone.py +++ b/core/migrations/0064_userprofile_timezone.py @@ -4,15 +4,20 @@ class Migration(migrations.Migration): - dependencies = [ - ('core', '0063_alter_userprofile_dynamic_progress_and_more'), + ("core", "0063_alter_userprofile_dynamic_progress_and_more"), ] operations = [ migrations.AddField( - model_name='userprofile', - name='timezone', - field=models.CharField(blank=True, default='', help_text='Your local time zone for displaying timestamps. Leave blank to use server time (America/New_York).', max_length=63, verbose_name='Time zone'), + model_name="userprofile", + name="timezone", + field=models.CharField( + blank=True, + default="", + help_text="Your local time zone for displaying timestamps. Leave blank to use server time (America/New_York).", + max_length=63, + verbose_name="Time zone", + ), ), ] diff --git a/core/templates/core/userprofile_form.html b/core/templates/core/userprofile_form.html index 9548eb559..b54ddb302 100644 --- a/core/templates/core/userprofile_form.html +++ b/core/templates/core/userprofile_form.html @@ -36,19 +36,19 @@

Advanced settings

{% endblock layout-content %} From 0d7233f23532f62a11850cff1d29d2f9a930e905 Mon Sep 17 00:00:00 2001 From: Dean Menezes Date: Sun, 1 Mar 2026 15:32:04 -0600 Subject: [PATCH 03/14] fix: use getattr to satisfy pyright type checking in TimezoneMiddleware --- core/middleware.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/middleware.py b/core/middleware.py index 4034a5182..5e5209d10 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -34,7 +34,9 @@ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): def __call__(self, request: HttpRequest): if request.user.is_authenticated: - user_timezone = request.user.profile.timezone + user_timezone = getattr( + getattr(request.user, "profile", None), "timezone", "" + ) if user_timezone: try: timezone.activate(zoneinfo.ZoneInfo(user_timezone)) From b48586cb85fab00e4b704f02b20f0024da2418fa Mon Sep 17 00:00:00 2001 From: Dean Menezes Date: Sun, 1 Mar 2026 15:50:35 -0600 Subject: [PATCH 04/14] refactor: use get_or_create for UserProfile access in TimezoneMiddleware Replace nested getattr with get_or_create for better readability, matching the pattern used in LastSeenMiddleware above it. --- core/middleware.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/core/middleware.py b/core/middleware.py index 5e5209d10..1bdbf0399 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -34,12 +34,10 @@ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): def __call__(self, request: HttpRequest): if request.user.is_authenticated: - user_timezone = getattr( - getattr(request.user, "profile", None), "timezone", "" - ) - if user_timezone: + up, _ = UserProfile.objects.get_or_create(user=request.user) + if up.timezone: try: - timezone.activate(zoneinfo.ZoneInfo(user_timezone)) + timezone.activate(zoneinfo.ZoneInfo(up.timezone)) except zoneinfo.ZoneInfoNotFoundError: timezone.deactivate() else: From c1eaea40ee285de8e77094fc1f5f3049009272cd Mon Sep 17 00:00:00 2001 From: Dean Menezes Date: Sun, 1 Mar 2026 16:05:54 -0600 Subject: [PATCH 05/14] refactor: improve timezone feature based on feedback - Add timezone validation to prevent invalid values in database - Remove unnecessary timezone.deactivate() calls in middleware - Move JavaScript to proper scripts block - Change auto-fill to suggestion UX with dismissible alert - Remove explicit UTC timezone display from pset queue - Add comprehensive unit tests for timezone functionality - Validator tests for valid/invalid timezones - UserProfile model validation tests - Middleware tests for authenticated/unauthenticated users - Form integration tests --- core/middleware.py | 4 +- .../0065_alter_userprofile_timezone.py | 19 +++ core/models.py | 11 ++ core/templates/core/userprofile_form.html | 37 +++++- core/tests.py | 116 ++++++++++++++++++ .../templates/dashboard/pset_queue_list.html | 4 +- 6 files changed, 182 insertions(+), 9 deletions(-) create mode 100644 core/migrations/0065_alter_userprofile_timezone.py diff --git a/core/middleware.py b/core/middleware.py index 1bdbf0399..55b13f1b8 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -39,9 +39,7 @@ def __call__(self, request: HttpRequest): try: timezone.activate(zoneinfo.ZoneInfo(up.timezone)) except zoneinfo.ZoneInfoNotFoundError: - timezone.deactivate() - else: - timezone.deactivate() + pass # Fall back to default timezone response = self.get_response(request) timezone.deactivate() return response diff --git a/core/migrations/0065_alter_userprofile_timezone.py b/core/migrations/0065_alter_userprofile_timezone.py new file mode 100644 index 000000000..65a26e503 --- /dev/null +++ b/core/migrations/0065_alter_userprofile_timezone.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.11 on 2026-03-01 22:05 + +import core.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0064_userprofile_timezone'), + ] + + operations = [ + migrations.AlterField( + model_name='userprofile', + name='timezone', + field=models.CharField(blank=True, default='', help_text='Your local time zone for displaying timestamps. Leave blank to use server time (America/New_York).', max_length=63, validators=[core.models.validate_timezone], verbose_name='Time zone'), + ), + ] diff --git a/core/models.py b/core/models.py index c065b7709..352ac86b8 100644 --- a/core/models.py +++ b/core/models.py @@ -2,9 +2,11 @@ import datetime import os +import zoneinfo from typing import Callable from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.db import models from django.db.models.manager import BaseManager from django.urls import reverse @@ -14,6 +16,14 @@ # Create your models here. +def validate_timezone(value: str) -> None: + if value: # blank is allowed + try: + zoneinfo.ZoneInfo(value) + except zoneinfo.ZoneInfoNotFoundError: + raise ValidationError(f"{value} is not a valid timezone") + + class Semester(models.Model): """Represents an academic semester/year/etc, e.g. "Fall 2017" or "Year III".""" @@ -304,6 +314,7 @@ class UserProfile(models.Model): default="", verbose_name="Time zone", help_text="Your local time zone for displaying timestamps. Leave blank to use server time (America/New_York).", + validators=[validate_timezone], ) email_on_announcement = models.BooleanField( diff --git a/core/templates/core/userprofile_form.html b/core/templates/core/userprofile_form.html index b54ddb302..1a6f4dc08 100644 --- a/core/templates/core/userprofile_form.html +++ b/core/templates/core/userprofile_form.html @@ -33,17 +33,46 @@

Display settings

Advanced settings

{% for field in advanced_fields %}{{ field|as_crispy_field }}{% endfor %} + +{% endblock layout-content %} +{% block scripts %} -{% endblock layout-content %} +{% endblock scripts %} diff --git a/core/tests.py b/core/tests.py index 031e004b6..ec6aca939 100644 --- a/core/tests.py +++ b/core/tests.py @@ -452,3 +452,119 @@ def test_set_default_artwork_action_single_unit_group(): assert ug.artwork == "artwork/test-unit.png" assert ug.artwork_thumb_md == "artwork-thumb-md/test-unit.png" assert ug.artwork_thumb_sm == "artwork-thumb-sm/test-unit.png" + + +@pytest.mark.django_db +def test_timezone_validator(): + from django.core.exceptions import ValidationError + + from core.models import validate_timezone + + # Valid timezones should not raise + validate_timezone("") # blank is allowed + validate_timezone("America/New_York") + validate_timezone("Europe/London") + validate_timezone("Asia/Tokyo") + validate_timezone("UTC") + + # Invalid timezones should raise ValidationError + with pytest.raises(ValidationError): + validate_timezone("Invalid/Timezone") + with pytest.raises(ValidationError): + validate_timezone("Not_A_Timezone") + + +@pytest.mark.django_db +def test_user_profile_timezone(): + from django.core.exceptions import ValidationError + + from core.models import UserProfile + + user = UserFactory.create() + profile = UserProfile.objects.create(user=user, timezone="America/Los_Angeles") + profile.full_clean() # Should not raise + assert profile.timezone == "America/Los_Angeles" + + # Test blank timezone is allowed + profile.timezone = "" + profile.full_clean() # Should not raise + + # Test invalid timezone raises validation error + profile.timezone = "Invalid/Zone" + with pytest.raises(ValidationError): + profile.full_clean() + + +@pytest.mark.django_db +def test_timezone_middleware(): + from unittest.mock import Mock, PropertyMock + + from core.middleware import TimezoneMiddleware + from core.models import UserProfile + + # Test 1: User with valid timezone + user1 = UserFactory.create() + UserProfile.objects.create(user=user1, timezone="America/Los_Angeles") + + request = Mock() + request.user = user1 + type(request.user).is_authenticated = PropertyMock(return_value=True) + + mock_response = Mock() + get_response = Mock(return_value=mock_response) + middleware = TimezoneMiddleware(get_response) + + response = middleware(request) + assert response == mock_response + get_response.assert_called_once() + + # Test 2: User with no timezone set (should use default) + user2 = UserFactory.create() + UserProfile.objects.create(user=user2, timezone="") + + request2 = Mock() + request2.user = user2 + type(request2.user).is_authenticated = PropertyMock(return_value=True) + + get_response2 = Mock(return_value=mock_response) + middleware2 = TimezoneMiddleware(get_response2) + + response2 = middleware2(request2) + assert response2 == mock_response + get_response2.assert_called_once() + + # Test 3: Unauthenticated user + request3 = Mock() + request3.user = Mock() + type(request3.user).is_authenticated = PropertyMock(return_value=False) + + get_response3 = Mock(return_value=mock_response) + middleware3 = TimezoneMiddleware(get_response3) + + response3 = middleware3(request3) + assert response3 == mock_response + get_response3.assert_called_once() + + +@pytest.mark.django_db +def test_timezone_in_profile_form(otis): + from core.models import UserProfile + + user = UserFactory.create() + otis.login(user) + + # Get the profile preferences page + response = otis.get("profile") + + # Verify timezone field is in the form + content = response.content.decode() + assert "timezone" in content.lower() or "time zone" in content.lower() + assert "id_timezone" in content + + # Verify we can set and save timezone directly on model + profile, _ = UserProfile.objects.get_or_create(user=user) + profile.timezone = "Europe/Paris" + profile.save() + + profile.refresh_from_db() + assert profile.timezone == "Europe/Paris" diff --git a/dashboard/templates/dashboard/pset_queue_list.html b/dashboard/templates/dashboard/pset_queue_list.html index 6145147b1..522abfc3a 100644 --- a/dashboard/templates/dashboard/pset_queue_list.html +++ b/dashboard/templates/dashboard/pset_queue_list.html @@ -11,7 +11,7 @@ # Unit - Timestamp (UTC) + Timestamp PK @@ -30,7 +30,7 @@ {% endif %} {% endif %} - {{ pset.upload.created_at|timezone:"UTC"|date:"Y-m-d H:i" }} + {{ pset.upload.created_at|date:"Y-m-d H:i" }} {% if request.user.is_staff %} {{ pset.pk }} From 646c8b971cc17cb2e5a380408a064ddfeda8f694 Mon Sep 17 00:00:00 2001 From: Dean Menezes Date: Sun, 1 Mar 2026 16:08:30 -0600 Subject: [PATCH 06/14] fix: apply ruff formatting to migration file --- .../0065_alter_userprofile_timezone.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/core/migrations/0065_alter_userprofile_timezone.py b/core/migrations/0065_alter_userprofile_timezone.py index 65a26e503..ea04c91fb 100644 --- a/core/migrations/0065_alter_userprofile_timezone.py +++ b/core/migrations/0065_alter_userprofile_timezone.py @@ -1,19 +1,26 @@ # Generated by Django 5.2.11 on 2026-03-01 22:05 -import core.models from django.db import migrations, models +import core.models + class Migration(migrations.Migration): - dependencies = [ - ('core', '0064_userprofile_timezone'), + ("core", "0064_userprofile_timezone"), ] operations = [ migrations.AlterField( - model_name='userprofile', - name='timezone', - field=models.CharField(blank=True, default='', help_text='Your local time zone for displaying timestamps. Leave blank to use server time (America/New_York).', max_length=63, validators=[core.models.validate_timezone], verbose_name='Time zone'), + model_name="userprofile", + name="timezone", + field=models.CharField( + blank=True, + default="", + help_text="Your local time zone for displaying timestamps. Leave blank to use server time (America/New_York).", + max_length=63, + validators=[core.models.validate_timezone], + verbose_name="Time zone", + ), ), ] From 2dfc93218020777d769430b729588876d96c998c Mon Sep 17 00:00:00 2001 From: Dean Menezes Date: Thu, 5 Mar 2026 17:14:49 -0600 Subject: [PATCH 07/14] fix: avoid timezone middleware writes and improve timezone picker --- core/middleware.py | 4 ++-- core/templates/core/userprofile_form.html | 11 ++++++++++- core/views.py | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/core/middleware.py b/core/middleware.py index 55b13f1b8..47ed27a3c 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -34,8 +34,8 @@ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): def __call__(self, request: HttpRequest): if request.user.is_authenticated: - up, _ = UserProfile.objects.get_or_create(user=request.user) - if up.timezone: + up = UserProfile.objects.filter(user=request.user).only("timezone").first() + if up and up.timezone: try: timezone.activate(zoneinfo.ZoneInfo(up.timezone)) except zoneinfo.ZoneInfoNotFoundError: diff --git a/core/templates/core/userprofile_form.html b/core/templates/core/userprofile_form.html index 1a6f4dc08..b5855d85f 100644 --- a/core/templates/core/userprofile_form.html +++ b/core/templates/core/userprofile_form.html @@ -33,6 +33,9 @@

Display settings

Advanced settings

{% for field in advanced_fields %}{{ field|as_crispy_field }}{% endfor %} + + {% for tz in timezone_choices %}{% endfor %} +