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 }}
+
+ Detected timezone:
+
+
+
+ {{ 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 '