Skip to content

Commit 49336af

Browse files
author
Nils Bars
committed
Render exercise deadlines in the configured system timezone
The edit-configuration form previously dumped the naive-UTC deadline straight into a datetime-local input, so admins saw UTC wall-clock while the SSH banner and scoreboard (which route through utc_datetime_to_local_tz) displayed the system-timezone value. Convert naive-UTC to the system timezone on the GET path, render the admin "View Exercise" deadline fields through a new localdt Jinja filter, and replace the native datetime-local picker with a flatpickr instance (24h, Monday week start, altInput for a friendly display, defaultDate/ defaultHour/defaultMinute pre-populated from the current value). Init runs on window.load so bootstrap-material-design's form-group wrapping does not clobber flatpickr.
1 parent 3d2b609 commit 49336af

5 files changed

Lines changed: 127 additions & 7 deletions

File tree

tests/unit/test_util.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,19 @@
44
Tests for utility functions that don't require Flask/DB context.
55
"""
66

7+
import datetime
8+
79
import pytest
810
from unittest.mock import MagicMock, patch
911
from colorama import Fore, Style
1012

1113
from ref.core.util import (
1214
AnsiColorUtil,
15+
datetime_to_string,
1316
is_db_serialization_error,
1417
is_deadlock_error,
1518
ssh_key_basename,
19+
utc_datetime_to_local_tz,
1620
)
1721

1822

@@ -268,3 +272,57 @@ def test_leading_whitespace_is_stripped(self):
268272

269273
def test_unknown_algo_falls_back_to_rsa(self):
270274
assert ssh_key_basename("bogus-algo AAAA") == "id_rsa"
275+
276+
277+
@pytest.mark.offline
278+
class TestUtcDatetimeToLocalTz:
279+
"""Conversion of naive-UTC datetimes (as stored in the DB) to the
280+
configured system timezone, for display purposes."""
281+
282+
def test_converts_naive_utc_to_berlin_cest(self):
283+
naive_utc = datetime.datetime(2026, 4, 26, 15, 59)
284+
with patch("ref.core.util.SystemSettingsManager") as ssm:
285+
ssm.TIMEZONE.value = "Europe/Berlin"
286+
local = utc_datetime_to_local_tz(naive_utc)
287+
assert local.strftime("%Y-%m-%dT%H:%M") == "2026-04-26T17:59"
288+
289+
def test_converts_naive_utc_to_berlin_cet_in_winter(self):
290+
naive_utc = datetime.datetime(2026, 1, 15, 12, 0)
291+
with patch("ref.core.util.SystemSettingsManager") as ssm:
292+
ssm.TIMEZONE.value = "Europe/Berlin"
293+
local = utc_datetime_to_local_tz(naive_utc)
294+
assert local.strftime("%Y-%m-%dT%H:%M") == "2026-01-15T13:00"
295+
296+
def test_utc_timezone_is_identity(self):
297+
naive_utc = datetime.datetime(2026, 4, 26, 15, 59)
298+
with patch("ref.core.util.SystemSettingsManager") as ssm:
299+
ssm.TIMEZONE.value = "UTC"
300+
local = utc_datetime_to_local_tz(naive_utc)
301+
assert local.strftime("%Y-%m-%dT%H:%M") == "2026-04-26T15:59"
302+
303+
def test_crosses_day_boundary(self):
304+
naive_utc = datetime.datetime(2026, 4, 26, 23, 30)
305+
with patch("ref.core.util.SystemSettingsManager") as ssm:
306+
ssm.TIMEZONE.value = "Europe/Berlin"
307+
local = utc_datetime_to_local_tz(naive_utc)
308+
assert local.strftime("%Y-%m-%dT%H:%M") == "2026-04-27T01:30"
309+
310+
311+
@pytest.mark.offline
312+
class TestDatetimeToString:
313+
"""Human-readable formatting of DB-stored (naive UTC) datetimes."""
314+
315+
def test_naive_utc_formatted_in_system_tz(self):
316+
naive_utc = datetime.datetime(2026, 4, 26, 15, 59, 0)
317+
with patch("ref.core.util.SystemSettingsManager") as ssm:
318+
ssm.TIMEZONE.value = "Europe/Berlin"
319+
s = datetime_to_string(naive_utc)
320+
assert s == "26/04/2026 17:59:00"
321+
322+
def test_aware_datetime_preserves_its_tz(self):
323+
from dateutil import tz as _tz
324+
325+
aware_utc = datetime.datetime(2026, 4, 26, 15, 59, 0, tzinfo=_tz.gettz("UTC"))
326+
# Aware datetimes pass through without re-conversion.
327+
s = datetime_to_string(aware_utc)
328+
assert s == "26/04/2026 15:59:00"

webapp/ref/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,11 +389,19 @@ def ansi2html_filter(s):
389389
ret = ansi2html.Ansi2HTMLConverter().convert(s, full=False)
390390
return ret
391391

392+
from ref.core.util import datetime_to_string
393+
394+
def localdt(ts):
395+
if ts is None:
396+
return "—"
397+
return datetime_to_string(ts)
398+
392399
app.jinja_env.filters["quote_plus"] = lambda u: urllib.parse.quote_plus(u)
393400
app.jinja_env.filters["any"] = any
394401
app.jinja_env.filters["all"] = all
395402
app.jinja_env.filters["not"] = lambda e: [not x for x in e]
396403
app.jinja_env.filters["ansi2html"] = ansi2html_filter
404+
app.jinja_env.filters["localdt"] = localdt
397405

398406
def syntax_highlight(val):
399407
try:

webapp/ref/templates/exercise_config_edit.html

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
{% extends "admin_base.html" %}
22
{% import "wtf_bootstrap_utils.html" as wtf_utils %}
33

4+
{% block head %}
5+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
6+
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
7+
{% endblock %}
8+
49
{% block title %}
510
Edit Exercise: {{ short_name }}
611
{% endblock %}
@@ -25,10 +30,16 @@ <h4 class="card-header">
2530

2631
<div class="row mb-3">
2732
<div class="col-md-6">
28-
{{ wtf_utils.render_field(form.submission_deadline_start, "datetime-local") }}
33+
{{ wtf_utils.render_field(form.submission_deadline_start, "text") }}
34+
<small class="form-text text-muted">
35+
Interpreted as {{ settings.TIMEZONE.value }} (system timezone).
36+
</small>
2937
</div>
3038
<div class="col-md-6">
31-
{{ wtf_utils.render_field(form.submission_deadline_end, "datetime-local") }}
39+
{{ wtf_utils.render_field(form.submission_deadline_end, "text") }}
40+
<small class="form-text text-muted">
41+
Interpreted as {{ settings.TIMEZONE.value }} (system timezone).
42+
</small>
3243
</div>
3344
</div>
3445

@@ -328,4 +339,39 @@ <h5>Per-task scoring policies</h5>
328339
serialize();
329340
})();
330341
</script>
342+
343+
<script>
344+
(function () {
345+
function parseIsoLocal(s) {
346+
var m = s && s.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/);
347+
if (!m) return null;
348+
return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]);
349+
}
350+
function init(id) {
351+
var el = document.getElementById(id);
352+
if (!el) return;
353+
var initial = parseIsoLocal(el.value);
354+
flatpickr(el, {
355+
enableTime: true,
356+
time_24hr: true,
357+
minuteIncrement: 1,
358+
allowInput: true,
359+
dateFormat: "Y-m-d\\TH:i",
360+
altInput: true,
361+
altFormat: "Y-m-d H:i",
362+
defaultDate: initial,
363+
defaultHour: initial ? initial.getHours() : 23,
364+
defaultMinute: initial ? initial.getMinutes() : 59,
365+
locale: { firstDayOfWeek: 1 },
366+
});
367+
}
368+
// bootstrap-material-design rewrites form-control inputs on
369+
// $(document).ready (wrapping them in .bmd-form-group), which clobbers
370+
// flatpickr if we init first. Run on `load` so BMD has finished.
371+
window.addEventListener("load", function () {
372+
init("submission_deadline_start");
373+
init("submission_deadline_end");
374+
});
375+
})();
376+
</script>
331377
{% endblock %}

webapp/ref/templates/exercise_view_single.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ <h5>Meta</h5>
2020
<dt class="col-sm-3">Submission Tests Enabled</dt>
2121
<dd class="col-sm-9">{{ exercise.submission_test_enabled }}</dd>
2222
<dt class="col-sm-3">Deadline Start</dt>
23-
<dd class="col-sm-9">{{ moment(exercise.submission_deadline_start).format() }}</dd>
23+
<dd class="col-sm-9">{{ exercise.submission_deadline_start | localdt }} <small class="text-muted">({{ settings.TIMEZONE.value }})</small></dd>
2424
<dt class="col-sm-3">Deadline End</dt>
25-
<dd class="col-sm-9">{{ moment(exercise.submission_deadline_end).format() }}</dd>
25+
<dd class="col-sm-9">{{ exercise.submission_deadline_end | localdt }} <small class="text-muted">({{ settings.TIMEZONE.value }})</small></dd>
2626
<dt class="col-sm-3">Build Status</dt>
2727
<dd class="col-sm-9">{{ exercise.build_job_status }}</dd>
2828
<dt class="col-sm-3">Build Result</dt><dd class="col-sm-9"></dd>

webapp/ref/view/exercise.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@
2828
)
2929
from ref.core.logging import get_logger
3030
from ref.core.security import sanitize_path_is_subdir
31-
from ref.core.util import datetime_transmute_into_local, redirect_to_next
31+
from ref.core.util import (
32+
datetime_transmute_into_local,
33+
redirect_to_next,
34+
utc_datetime_to_local_tz,
35+
)
3236
from ref.model import Exercise, ExerciseConfig
3337
from ref.model.enums import ExerciseBuildStatus
3438

@@ -503,12 +507,16 @@ def render():
503507
form.short_name.data = config.short_name
504508
form.category.data = config.category
505509
form.submission_deadline_start.data = (
506-
config.submission_deadline_start.strftime("%Y-%m-%dT%H:%M")
510+
utc_datetime_to_local_tz(config.submission_deadline_start).strftime(
511+
"%Y-%m-%dT%H:%M"
512+
)
507513
if config.submission_deadline_start
508514
else ""
509515
)
510516
form.submission_deadline_end.data = (
511-
config.submission_deadline_end.strftime("%Y-%m-%dT%H:%M")
517+
utc_datetime_to_local_tz(config.submission_deadline_end).strftime(
518+
"%Y-%m-%dT%H:%M"
519+
)
512520
if config.submission_deadline_end
513521
else ""
514522
)

0 commit comments

Comments
 (0)