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
18 changes: 18 additions & 0 deletions core/middleware.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import zoneinfo
from typing import Callable

from django.core.cache import cache
Expand Down Expand Up @@ -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()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually, now that i think about it, why is there a timezone.deactivate here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok as far as I can understand, timezone.activate() sets Django timezone in request-local context, and that context can be reused by later requests on the same worker.
timezone.deactivate() clears it so the next request doesn’t accidentally inherit another user’s timezone and instead falls back to settings.TIME_ZONE.

An alternative is not to set a global request timezone at all; convert datetimes only where needed in templates.

return response
26 changes: 26 additions & 0 deletions core/migrations/0065_userprofile_timezone.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
18 changes: 18 additions & 0 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"."""
Expand Down Expand Up @@ -298,6 +308,14 @@ class UserProfile(models.Model):
help_text="Hide all hints from the problem archive (ARCH).",
default=False,
)
timezone = models.CharField(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so i don't know if this is possible, but i think this would be better with CHOICES somehow if it could be done. basically i don't think it's good UX for a end-user to have to type the exact case-sensitive string "America/Los_Angeles"... I would be unable to remember that.

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",
Expand Down
118 changes: 118 additions & 0 deletions core/templates/core/userprofile_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,125 @@ <h1>Display settings</h1>
<div class="section">
<h1>Advanced settings</h1>
{% for field in advanced_fields %}{{ field|as_crispy_field }}{% endfor %}
<div class="mb-2">
<label for="timezone-search" class="form-label">Search time zone</label>
<input type="text"
class="form-control"
id="timezone-search"
placeholder="Type to filter, e.g. Seoul or Korea" />
</div>
{{ timezone_field|as_crispy_field }}
<button type="submit" class="btn btn-success">Update settings</button>
<div id="timezone-suggestion"
class="alert alert-info mt-2"
style="display: none">
Detected timezone: <strong id="detected-tz"></strong>
<button type="button"
class="btn btn-sm btn-primary ms-2"
id="use-detected-tz">Use this timezone</button>
<button type="button"
class="btn-close float-end"
aria-label="Close"
id="dismiss-tz-suggestion"></button>
</div>
</div>
{{ timezone_extra_aliases|json_script:"timezone-extra-aliases" }}
</form>
{% endblock layout-content %}
{% block scripts %}
<script>
// Show timezone suggestion if field is empty
document.addEventListener('DOMContentLoaded', function() {
const timezoneField = document.getElementById('id_timezone');
const timezoneSearch = document.getElementById('timezone-search');
const suggestionDiv = document.getElementById('timezone-suggestion');
const detectedTzSpan = document.getElementById('detected-tz');
const useButton = document.getElementById('use-detected-tz');
const dismissButton = document.getElementById('dismiss-tz-suggestion');
const aliasesScript = document.getElementById('timezone-extra-aliases');
let aliasesByTimezone = {};
if (aliasesScript) {
aliasesByTimezone = JSON.parse(aliasesScript.textContent);
}

function getTimezoneNameForSearch(timezoneName) {
if (!timezoneName) {
return '';
}
try {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timezoneName,
timeZoneName: 'longGeneric',
}).formatToParts(new Date());
const zonePart = parts.find(function(part) {
return part.type === 'timeZoneName';
});
return zonePart ? zonePart.value : '';
} catch (error) {
return '';
}
}

let allOptions = [];
if (timezoneField) {
allOptions = Array.from(timezoneField.options).map(function(opt) {
const aliasText = (aliasesByTimezone[opt.value] || []).join(' ');
const zoneNameText = getTimezoneNameForSearch(opt.value);
return {
value: opt.value,
label: opt.textContent,
searchText: (opt.textContent + ' ' + zoneNameText + ' ' + aliasText).toLowerCase()
};
});
}

if (timezoneSearch && timezoneField) {
timezoneSearch.addEventListener('input', function() {
const query = timezoneSearch.value.toLowerCase().trim();
const selectedValue = timezoneField.value;
const filtered = allOptions.filter(function(opt) {
if (!query) {
return true;
}
return opt.searchText.includes(query);
});
timezoneField.innerHTML = '';
filtered.forEach(function(opt) {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
timezoneField.appendChild(option);
});
if (filtered.some(function(opt) {
return opt.value === selectedValue;
})) {
timezoneField.value = selectedValue;
} else if (filtered.length > 0) {
timezoneField.selectedIndex = 0;
}
});
}

if (timezoneField && !timezoneField.value && suggestionDiv) {
try {
const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (detectedTimezone) {
detectedTzSpan.textContent = detectedTimezone;
suggestionDiv.style.display = 'block';

useButton.addEventListener('click', function() {
timezoneField.value = detectedTimezone;
suggestionDiv.style.display = 'none';
});

dismissButton.addEventListener('click', function() {
suggestionDiv.style.display = 'none';
});
}
} catch (error) {
// Fail silently if auto-detection isn't available.
}
}
});
</script>
{% endblock scripts %}
Loading