diff --git a/core/middleware.py b/core/middleware.py index e9587775..47ed27a3 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -1,3 +1,4 @@ +import zoneinfo from typing import Callable from django.core.cache import cache @@ -25,3 +26,20 @@ 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: + 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: + pass # Fall back to default timezone + response = self.get_response(request) + timezone.deactivate() + return response diff --git a/core/migrations/0065_userprofile_timezone.py b/core/migrations/0065_userprofile_timezone.py new file mode 100644 index 00000000..39c22f1a --- /dev/null +++ b/core/migrations/0065_userprofile_timezone.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.12 on 2026-03-05 23:37 + +from django.db import migrations, models + +import core.models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0064_userprofile_disable_hints"), + ] + + 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, + validators=[core.models.validate_timezone], + verbose_name="Time zone", + ), + ), + ] diff --git a/core/models.py b/core/models.py index 948c7b34..352ac86b 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".""" @@ -298,6 +308,14 @@ 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).", + validators=[validate_timezone], + ) 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 e3f91250..5b467b79 100644 --- a/core/templates/core/userprofile_form.html +++ b/core/templates/core/userprofile_form.html @@ -32,7 +32,125 @@

Display settings

Advanced settings

{% for field in advanced_fields %}{{ field|as_crispy_field }}{% endfor %} +
+ + +
+ {{ timezone_field|as_crispy_field }} +
+ {{ timezone_extra_aliases|json_script:"timezone-extra-aliases" }} {% endblock layout-content %} +{% block scripts %} + +{% endblock scripts %} diff --git a/core/tests.py b/core/tests.py index 031e004b..48ebf139 100644 --- a/core/tests.py +++ b/core/tests.py @@ -452,3 +452,179 @@ 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_middleware_does_not_create_profile(): + from django.http import HttpResponse + from django.test import RequestFactory + + from core.middleware import TimezoneMiddleware + from core.models import UserProfile + + user = UserFactory.create() + assert not UserProfile.objects.filter(user=user).exists() + + request = RequestFactory().get("/") + request.user = user + + middleware = TimezoneMiddleware(lambda _: HttpResponse("ok")) + middleware(request) + + assert not UserProfile.objects.filter(user=user).exists() + + +@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 + assert '