feat: times in the reader's own zone; the audit log shows exact moments (milestone 490)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m0s
CI / unit (push) Failing after 1m23s
CI / publish (push) Skipped

- users.time_zone (0034). An account without one takes the browser's zone
  on its first page (POST /account/time-zone/adopt, never over a choice made
  by hand); the session follows the account on every request.
- /account: who you are and the zone every page writes its times in,
  reached from your name in the sidebar.
- formatting.when: 'Today 06:58', 'Yesterday 23:41', 'Tue 6 Oct 14:12',
  '6 Oct 2025 14:12', 24-hour, in the reader's zone; |ago's hover is the
  exact local moment instead of UTC; tz_label() for column headers.
- Audit log: When (EDT) with exact times, one detail fact a line, on classes.
- tzdata in the image, and the airgapped smoke checks a zone resolves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 07:56:27 -04:00
co-authored by Claude Opus 5.5
parent 955450d33f
commit 6a81cf4aac
19 changed files with 425 additions and 25 deletions
+4
View File
@@ -206,6 +206,10 @@ jobs:
# tini is PID 1, so zombies are reaped and SIGTERM reaches Steward.
test "$(docker exec "$SMOKE-app" cat /proc/1/comm)" = "tini"
# The zone database is in the image, so a reader's zone resolves
# instead of quietly falling back to UTC (milestone 490).
docker exec "$SMOKE-app" python -c 'from zoneinfo import ZoneInfo; ZoneInfo("America/New_York")'
# A fresh database lands on first-run setup: a real page, its assets
# served by Steward itself, and no third-party host in it (rule 155).
fetch / > /tmp/smoke-page.html
+3
View File
@@ -16,6 +16,9 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get update \
# tini — PID 1: reaps the zombies ssh and ansible leave behind and
# forwards SIGTERM to Steward so a stop is a graceful shutdown
tini \
# tzdata — the zone database, so times show in each reader's own zone
# (milestone 490); Debian's slim base no longer carries it
tzdata \
&& rm -rf /var/lib/apt/lists/*
# Upgrade pip to suppress the version notice
View File
+84
View File
@@ -0,0 +1,84 @@
"""The signed-in user's own account (milestone 490): the time zone every
page writes its times in. Any role reaches it, for themselves only."""
from __future__ import annotations
from itertools import groupby
from quart import Blueprint, abort, current_app, redirect, render_template, request, session
from steward.auth.middleware import require_role
from steward.core import formatting
from steward.models.users import User, UserRole
account_bp = Blueprint("account", __name__, url_prefix="/account")
_SOURCES = {"local": "a Steward password", "ldap": "LDAP", "oidc": "single sign-on"}
def _zones_by_region() -> list[tuple[str, list[str]]]:
"""The Account page's choices: UTC alone, then every zone under its
region ('America', 'Europe', …)."""
named = [z for z in formatting.zone_names() if "/" in z]
return [("", ["UTC"])] + [(region, list(zones)) for region, zones
in groupby(named, key=lambda z: z.split("/", 1)[0])]
async def _me(db) -> User:
# A share link opens widgets read-only; it has no account.
user_id = session.get("user_id")
if not user_id:
abort(403)
user = await db.get(User, user_id)
if user is None:
abort(403)
return user
@account_bp.get("/")
@require_role(UserRole.viewer)
async def account(error: str | None = None, status: int = 200):
async with current_app.db_sessionmaker() as db:
user = await _me(db)
tz = formatting.zone(user.time_zone)
return await render_template(
"account/index.html", user=user, source=_SOURCES.get(user.auth_source, user.auth_source),
zone_name=user.time_zone or "UTC", zone_label=formatting.tz_label(tz),
zones=_zones_by_region(), error=error), status
@account_bp.post("/")
@require_role(UserRole.viewer)
async def account_save():
from steward.core.audit import log_audit
name = ((await request.form).get("time_zone") or "").strip()
if name not in formatting.zone_names():
return await account(f"{name or 'That'} is not a time zone Steward knows.", 400)
async with current_app.db_sessionmaker() as db:
async with db.begin():
user = await _me(db)
before = user.time_zone
user.time_zone = name
session["tz"] = name
if before != name:
await log_audit(current_app, user.id, user.username, "account.time_zone",
detail={"time_zone": name, "was": before or "UTC"})
return redirect("/account/")
@account_bp.post("/time-zone/adopt")
@require_role(UserRole.viewer)
async def adopt_time_zone():
"""The browser's zone, saved for an account that has none yet: base.html
sends it once, on the first page after a sign-in without one. A zone set
on the Account page is never replaced. Answers whether it was saved."""
name = ((await request.get_json(silent=True)) or {}).get("time_zone") or ""
if name not in formatting.zone_names():
return {"adopted": False}, 400
async with current_app.db_sessionmaker() as db:
async with db.begin():
user = await _me(db)
if user.time_zone is not None:
return {"adopted": False}
user.time_zone = name
session["tz"] = name
return {"adopted": True}
+11 -1
View File
@@ -138,7 +138,15 @@ def create_app(
app.jinja_env.filters["bytes"] = _fmt.human_bytes
app.jinja_env.filters["rate"] = _fmt.human_rate
app.jinja_env.filters["duration"] = _fmt.human_duration
app.jinja_env.filters["ago"] = _fmt.ago
# A moment is written in the reader's zone, saved on their account and held
# in the session (milestone 490); outside a request (an email, a report)
# it is UTC.
def _viewer_zone():
from quart import has_request_context, session
return _fmt.zone(session.get("tz")) if has_request_context() else _fmt.zone(None)
app.jinja_env.filters["ago"] = lambda moment, now=None: _fmt.ago(moment, now, tz=_viewer_zone())
app.jinja_env.filters["when"] = lambda moment: _fmt.when(moment, _viewer_zone())
app.jinja_env.globals["tz_label"] = lambda: _fmt.tz_label(_viewer_zone())
app.jinja_env.globals["status_class"] = (
lambda value, kind: _fmt.status_class(value, kind, app.config.get("THRESHOLDS", {}))
)
@@ -211,6 +219,7 @@ def create_app(
from .settings.routes import settings_bp
from .audit.routes import audit_bp
from .ai_access.routes import ai_access_bp
from .account.routes import account_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
@@ -223,6 +232,7 @@ def create_app(
app.register_blueprint(settings_bp)
app.register_blueprint(audit_bp)
app.register_blueprint(ai_access_bp)
app.register_blueprint(account_bp)
# Register the unified Monitor status source for the Status page. Plugins
# may register additional sources from setup(). Idempotent.
+2 -1
View File
@@ -92,6 +92,7 @@ ACTIONS = {
"auth.login": "signed in",
"auth.login_failed": "failed to sign in",
"auth.setup": "set up Steward",
"account.time_zone": "set their time zone",
"host.created": "added host",
"host.renamed": "renamed host",
"host.deleted": "deleted host",
@@ -143,7 +144,7 @@ ACTIONS = {
}
# The filter's choices: an area, matched as a prefix of the action code.
AREAS = (("auth.", "Sign-ins"), ("host.", "Hosts"), ("monitor.", "Monitors"),
AREAS = (("auth.", "Sign-ins"), ("account.", "Accounts"), ("host.", "Hosts"), ("monitor.", "Monitors"),
("alert_rule.", "Alert rules"), ("maintenance_window.", "Maintenance"),
("ansible.run", "Playbook runs"), ("ansible.", "Ansible (all)"),
("settings.", "Settings"), ("plugin.", "Plugins"), ("api_token.", "AI access"),
+6 -1
View File
@@ -117,6 +117,7 @@ def login_user(user) -> None:
session["user_id"] = user.id
session["user_role"] = user.role.value
session["username"] = user.username
session["tz"] = user.time_zone
session["login_at"] = time.time()
@@ -127,7 +128,7 @@ async def refresh_session_user() -> None:
it began (not since the last request: the cookie is re-signed on every
response, so the cookie's own age would let an active session live
forever). A user who has been deleted or deactivated is logged out on
their next request, and a role change applies at once.
their next request, and a role change or a new time zone applies at once.
"""
user_id = session.get("user_id")
if not user_id:
@@ -146,6 +147,10 @@ async def refresh_session_user() -> None:
return
if session.get("user_role") != user.role.value:
session["user_role"] = user.role.value
# The zone times are written in (milestone 490): a change made on the
# Account page, or the browser's zone saved for an account without one.
if session.get("tz") != user.time_zone:
session["tz"] = user.time_zone
def logout_user() -> None:
+68 -7
View File
@@ -2,13 +2,17 @@
How Steward writes a size, a rate, a span of time and a moment, and how it
grades a reading against Settings → Thresholds — once, for every page and
plugin (#5093). Registered as Jinja filters (|bytes, |rate, |duration, |ago)
and the status_class global in app.py; Python callers import them from here.
plugin (#5093). Registered as Jinja filters (|bytes, |rate, |duration, |ago,
|when) and the status_class global in app.py; Python callers import them from
here. A moment is written in the reader's own zone, on a 24-hour clock
(milestone 490); app.py hands the filters the zone saved on their account.
"""
from __future__ import annotations
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone, tzinfo
from functools import lru_cache
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
from markupsafe import Markup, escape
@@ -65,9 +69,43 @@ def seconds_since(when: datetime | None, now: datetime | None = None) -> float |
return ((now or datetime.now(timezone.utc)) - _utc(when)).total_seconds()
def ago(when: datetime | None, now: datetime | None = None) -> Markup:
def zone(name: str | None) -> tzinfo:
"""The zone a reader asked for by its IANA name, or UTC when none was
given or it is not one this machine knows."""
if not name:
return timezone.utc
try:
return ZoneInfo(name)
except (ZoneInfoNotFoundError, ValueError):
return timezone.utc
@lru_cache(maxsize=1)
def zone_names() -> tuple[str, ...]:
"""Every IANA zone this machine knows, sorted: the Account page's choices,
and what a saved zone is checked against."""
return tuple(sorted(n for n in available_timezones() if "/" in n or n == "UTC"))
def tz_label(tz: tzinfo | None = None, now: datetime | None = None) -> str:
"""The zone's short name as it stands now ('EDT', 'CET', 'UTC'), for a
column header that says which zone its times are in."""
return (now or datetime.now(timezone.utc)).astimezone(tz or timezone.utc).tzname() or "UTC"
def _exact(local: datetime) -> str:
"""'Thu 8 Oct 2026 06:58:12 EDT': the full moment, for a hover."""
return f"{local:%a} {local.day} {local:%b %Y %H:%M:%S} {local.tzname()}"
def _time(when: datetime, local: datetime, text: str) -> Markup:
return Markup('<time datetime="{}" title="{}">{}</time>').format(
when.strftime("%Y-%m-%dT%H:%M:%SZ"), _exact(local), escape(text))
def ago(when: datetime | None, now: datetime | None = None, tz: tzinfo | None = None) -> Markup:
"""'3m ago' (or 'in 3m' for a future moment) in a <time> element whose
title is the absolute UTC time, so a hover gives the exact moment."""
title is the exact moment in the reader's zone, so a hover gives it."""
if when is None:
return Markup(NONE)
when = _utc(when)
@@ -78,8 +116,31 @@ def ago(when: datetime | None, now: datetime | None = None) -> Markup:
text = f"{human_duration(secs)} ago"
else:
text = f"in {human_duration(-secs)}"
return Markup('<time datetime="{}" title="{}">{}</time>').format(
when.strftime("%Y-%m-%dT%H:%M:%SZ"), when.strftime("%Y-%m-%d %H:%M:%S UTC"), escape(text))
return _time(when, when.astimezone(tz or timezone.utc), text)
def when(moment: datetime | None, tz: tzinfo | None = None, now: datetime | None = None) -> Markup:
"""The exact moment in the reader's zone on a 24-hour clock, as short as
it can be read: 'Today 06:58', 'Yesterday 23:41', 'Tomorrow 02:00',
'Tue 6 Oct 14:12' this year, '6 Oct 2025 14:12' before it."""
if moment is None:
return Markup(NONE)
tz = tz or timezone.utc
moment = _utc(moment)
local = moment.astimezone(tz)
today = (now or datetime.now(timezone.utc)).astimezone(tz).date()
day, hm = local.date(), f"{local:%H:%M}"
if day == today:
text = f"Today {hm}"
elif day == today - timedelta(days=1):
text = f"Yesterday {hm}"
elif day == today + timedelta(days=1):
text = f"Tomorrow {hm}"
elif day.year == today.year:
text = f"{local:%a} {day.day} {local:%b} {hm}"
else:
text = f"{day.day} {local:%b} {day.year} {hm}"
return _time(moment, local, text)
def status_class(value: Any, kind: str, thresholds: dict) -> str:
@@ -0,0 +1,22 @@
"""The zone each user reads times in (milestone 490)
Revision ID: 0034_user_time_zone
Revises: 0033_retire_uptime_widget
Create Date: 2026-10-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0034_user_time_zone"
down_revision: Union[str, None] = "0033_retire_uptime_widget"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("users", sa.Column("time_zone", sa.String(64), nullable=True))
def downgrade() -> None:
op.drop_column("users", "time_zone")
+4
View File
@@ -36,3 +36,7 @@ class User(Base):
auth_source: Mapped[str] = mapped_column(String(16), nullable=False, default="local", server_default="local")
# The provider's stable id: the LDAP entry DN, or the OIDC `sub`. None for local users.
external_id: Mapped[str | None] = mapped_column(String(512), nullable=True)
# The IANA zone this user reads times in (milestone 490), e.g.
# "America/New_York". Their browser's zone is saved the first time they
# sign in without one; they change it on the Account page. None reads as UTC.
time_zone: Mapped[str | None] = mapped_column(String(64), nullable=True)
+10 -2
View File
@@ -97,8 +97,12 @@ code { font-family: var(--fs-font-mono); font-size: 0.875em; background: var(--b
background: var(--red-dim); color: var(--red); font-size: 0.75rem; font-weight: 500;
font-variant-numeric: tabular-nums;
}
.side-user { border-top: 1px solid var(--border); padding: 0.7rem 1.25rem; }
.side-user { border-top: 1px solid var(--border); padding: 0.7rem 1.25rem;
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.side-user form { margin: 0; }
.side-user a { color: var(--text-muted); font-size: 0.84rem; text-decoration: none;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.side-user a:hover, .side-user a[aria-current="page"] { color: var(--text); }
.side-user button {
color: var(--text-muted); font-size: 0.84rem; text-decoration: none;
display: flex; align-items: center; gap: 0.4rem;
@@ -108,7 +112,8 @@ code { font-family: var(--fs-font-mono); font-size: 0.875em; background: var(--b
/* Keyboard focus: a clear inset ring on every interactive sidebar element. */
.sidebar .brand:focus-visible,
.side-nav a:focus-visible,
.side-user button:focus-visible {
.side-user button:focus-visible,
.side-user a:focus-visible {
outline: 2px solid var(--accent); outline-offset: -2px; color: var(--text);
}
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
@@ -193,6 +198,7 @@ strong, b, th { font-weight: 500; }
.filter-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.75rem; }
.filter-bar input[type=search] { flex: 1 1 16rem; width: auto; }
.filter-bar select { width: auto; flex: 0 0 auto; }
.page-actions .filter-bar { margin: 0; }
/* A small label that links somewhere, such as a group that filters the list. */
.chip { display: inline-block; margin: 0.1rem 0.3rem 0.1rem 0; padding: 0.05rem 0.45rem; font-size: 0.75rem;
color: var(--text-muted); border: 1px solid var(--border); border-radius: var(--fs-radius-sm); white-space: nowrap; }
@@ -219,6 +225,8 @@ strong, b, th { font-weight: 500; }
/* A second link inside a row that opens as a whole: it sits above the row's target. */
.table tr.row-link .row-inner { position: relative; z-index: 1; }
.table td.nowrap { white-space: nowrap; }
/* An audit entry's details, one fact a line (milestone 490). */
.audit-facts { font-size: 0.8rem; color: var(--text-muted); }
.cell-note { font-size: 0.78rem; color: var(--text-muted); margin-top: 0.2rem; }
/* An alert episode's own transitions, folded under it (#5316). */
.episode-steps { margin-top: 0.3rem; font-size: 0.8rem; }
+32
View File
@@ -0,0 +1,32 @@
{# account/index.html — the signed-in user's own account (milestone 490): who
they are, and the time zone every page writes its times in. #}
{% extends "base.html" %}
{% from "_macros.html" import page_header %}
{% block title %}Your account — Steward{% endblock %}
{% block content %}
{{ page_header("Your account") }}
<p class="page-verdict">
Signed in as <strong>{{ user.username }}</strong> ({{ user.role.value }}) with {{ source }}
· times show in <strong>{{ zone_name }}</strong> ({{ zone_label }})
</p>
<form method="post" action="/account/" class="form-page">{{ csrf_field() }}
<section class="card form-section" aria-labelledby="acct-time">
<h2 id="acct-time" class="section-title">Time zone</h2>
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
<div class="field">
<label class="field-label" for="acct-tz">Show times in</label>
<select class="input" id="acct-tz" name="time_zone">
{% for region, names in zones %}
{% if region %}<optgroup label="{{ region }}">{% endif %}
{% for z in names %}<option value="{{ z }}"{% if z == zone_name %} selected{% endif %}>{{ z.split('/', 1)[1]|replace('_', ' ') if region else z }}</option>{% endfor %}
{% if region %}</optgroup>{% endif %}
{% endfor %}
</select>
<span class="field-hint">Every page writes its times in this zone, on a 24-hour clock.</span>
</div>
</section>
<button type="submit" class="btn">Save</button>
</form>
{% endblock %}
+4 -6
View File
@@ -4,8 +4,8 @@
{% for item in events %}
{% set e = item.event %}
<tr>
<td style="white-space:nowrap;" title="{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ e.timestamp|ago }}</td>
<td style="font-size:0.85rem;">{{ e.username }}</td>
<td class="nowrap">{{ e.timestamp|when }}</td>
<td class="nowrap">{{ e.username }}</td>
<td title="{{ e.action }}">{{ item.action or e.action }}</td>
<td>
{% if item.entity %}
@@ -14,11 +14,9 @@
{% if item.entity.gone %}<span class="field-hint">(deleted)</span>{% endif %}
{% elif e.entity_type %}<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>{% endif %}
</td>
<td style="font-size:0.8rem;color:var(--text-muted);max-width:340px;">
<td class="audit-facts">
{% for f in item.facts %}
<span style="color:var(--text-dim);">{{ f.label }}</span>
{% if f.url %}<a href="{{ f.url }}">{{ f.text }}</a>{% else %}{{ f.text }}{% endif %}
{% if not loop.last %} &nbsp;·&nbsp; {% endif %}
<div><span class="tone-dim">{{ f.label }}</span> {% if f.url %}<a href="{{ f.url }}">{{ f.text }}</a>{% else %}{{ f.text }}{% endif %}</div>
{% endfor %}
</td>
</tr>
+5 -3
View File
@@ -1,10 +1,12 @@
{# audit/list.html — who changed what, newest first, each moment exact in the
reader's own zone (milestone 490): a record is read for when, not how long ago. #}
{% extends "base.html" %}
{% from "_macros.html" import page_header, empty_state %}
{% block title %}Audit log — Steward{% endblock %}
{% block content %}
{% call page_header("Audit log", "Who changed what, newest first.") %}
<form method="get" style="display:flex;gap:0.5rem;align-items:center;">
<select name="action" aria-label="Show only" style="font-size:0.85rem;" onchange="this.form.submit()">
<form class="filter-bar" method="get" action="/audit/">
<select name="action" aria-label="Show only" onchange="this.form.submit()">
<option value="">Everything</option>
{% for prefix, label in areas %}
<option value="{{ prefix }}" {% if action_filter == prefix %}selected{% endif %}>{{ label }}</option>
@@ -19,7 +21,7 @@
<table class="table">
<thead>
<tr>
<th>When</th>
<th>When ({{ tz_label() }})</th>
<th>User</th>
<th>Did</th>
<th>What</th>
+21 -1
View File
@@ -68,8 +68,9 @@
{% endif %}
</nav>
<div class="side-user">
<a href="/account/" title="Your account and time zone"{% if _p.startswith('/account') %} aria-current="page"{% endif %}>{{ session.username }}</a>
<form method="post" action="/logout">{{ csrf_field() }}
<button type="submit" title="Log out">⏻ {{ session.username }}</button>
<button type="submit" title="Log out" aria-label="Log out">⏻</button>
</form>
</div>
</aside>
@@ -106,6 +107,25 @@ document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNavOpen(false);
});
</script>
{% if session.user_id and not session.tz %}
<script>
// An account with no time zone yet takes this browser's, once (milestone 490);
// the page then reloads so its times are written in it.
(function () {
var tz;
try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (e) { return; }
if (!tz) return;
fetch('/account/time-zone/adopt', {
method: 'POST',
headers: {'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content},
body: JSON.stringify({time_zone: tz})
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d && d.adopted) location.reload(); })
.catch(function () {});
})();
</script>
{% endif %}
<main{% block main_class %}{% endblock %}>
{% if plugin_failures and session.user_role == 'admin' %}
+21
View File
@@ -86,3 +86,24 @@ async def test_the_row_says_what_was_done_not_its_code(app):
assert 'title="monitor.disabled">paused monitor</td>' in page
assert "dry run</span>" in page and "False" not in page
assert '<option value="monitor." selected>Monitors</option>' in page
async def test_each_entry_shows_its_exact_moment_in_the_readers_zone(app):
"""Milestone 490: a record is read for when, not how long ago."""
from quart import session
from steward.core import formatting as f
events = [{"event": _ev("host", "h1", "host.renamed"), "detail": {}, "entity": None,
"facts": [{"label": "was", "text": "web-0", "url": None},
{"label": "AI token", "text": "#1", "url": None}]}]
async with app.test_request_context("/audit/"):
session["tz"] = "America/New_York"
page = await render_template("audit/list.html", events=events, after=None, action_filter="")
ny = f.zone("America/New_York")
assert f"<th>When ({f.tz_label(ny)})</th>" in page
# 12:00 UTC is 08:00 in New York, and the hover names the zone.
assert f'<td class="nowrap">{f.when(events[0]["event"].timestamp, ny)}</td>' in page
assert "08:00:00 EDT" in page and " ago<" not in page
# One fact a line, labels quiet, and the table on classes.
assert '<div><span class="tone-dim">was</span> web-0</div>' in page
table = page[page.index('<table class="table">'):]
assert "style=" not in table
+45 -3
View File
@@ -44,17 +44,59 @@ def test_duration(secs, text):
(NOW + timedelta(minutes=5), "in 5m"),
(NOW, "just now"),
])
def test_ago_reads_relative_and_titles_the_absolute_utc_time(when, text):
def test_ago_reads_relative_and_titles_the_exact_moment(when, text):
out = str(f.ago(when, now=NOW))
stamp = when.replace(tzinfo=timezone.utc) if when.tzinfo is None else when
assert out == (f'<time datetime="{stamp:%Y-%m-%dT%H:%M:%SZ}" '
f'title="{stamp:%Y-%m-%d %H:%M:%S} UTC">{text}</time>')
f'title="{stamp:%a} {stamp.day} {stamp:%b %Y %H:%M:%S} UTC">{text}</time>')
def test_ago_of_nothing_and_of_another_zone():
assert str(f.ago(None)) == "—"
est = timezone(timedelta(hours=-4))
assert 'title="2026-10-06 18:00:00 UTC"' in str(f.ago(NOW.astimezone(est), now=NOW))
assert 'title="Tue 6 Oct 2026 18:00:00 UTC"' in str(f.ago(NOW.astimezone(est), now=NOW))
# Milestone 490: a moment in the reader's own zone, on a 24-hour clock.
NY = f.zone("America/New_York")
def test_ago_hovers_the_moment_in_the_readers_zone():
assert 'title="Tue 6 Oct 2026 13:57:00 EDT">3m ago<' in str(
f.ago(NOW - timedelta(minutes=3), now=NOW, tz=NY))
@pytest.mark.parametrize("moment, text, title", [
(NOW - timedelta(hours=1), "Today 13:00", "Tue 6 Oct 2026 13:00:00 EDT"),
# 03:30 UTC on the 6th is 23:30 on the 5th in New York: the zone moves the day.
(datetime(2026, 10, 6, 3, 30, tzinfo=timezone.utc), "Yesterday 23:30", "Mon 5 Oct 2026 23:30:00 EDT"),
(datetime(2026, 10, 7, 6, 0, tzinfo=timezone.utc), "Tomorrow 02:00", "Wed 7 Oct 2026 02:00:00 EDT"),
(datetime(2026, 9, 30, 12, 0, tzinfo=timezone.utc), "Wed 30 Sep 08:00", "Wed 30 Sep 2026 08:00:00 EDT"),
# Before this year the year shows; in winter the zone is EST, an hour further back.
(datetime(2025, 12, 25, 12, 0, tzinfo=timezone.utc), "25 Dec 2025 07:00", "Thu 25 Dec 2025 07:00:00 EST"),
(datetime(2026, 10, 6, 21, 5), "Today 17:05", "Tue 6 Oct 2026 17:05:00 EDT"), # naive is UTC
])
def test_when_writes_the_exact_moment_in_the_readers_zone(moment, text, title):
stamp = moment.replace(tzinfo=timezone.utc) if moment.tzinfo is None else moment
assert str(f.when(moment, NY, now=NOW)) == (
f'<time datetime="{stamp:%Y-%m-%dT%H:%M:%SZ}" title="{title}">{text}</time>')
def test_when_is_a_24_hour_clock_and_utc_without_a_zone():
assert ">Today 18:00<" in str(f.when(NOW, now=NOW))
assert ">Today 23:59<" in str(f.when(NOW.replace(hour=23, minute=59), now=NOW))
assert str(f.when(None)) == "—"
def test_zone_falls_back_to_utc_and_names_itself():
assert f.zone(None) is timezone.utc and f.zone("") is timezone.utc
assert f.zone("Not/AZone") is timezone.utc and f.zone("../etc/passwd") is timezone.utc
assert f.tz_label(NY, now=NOW) == "EDT"
assert f.tz_label(NY, now=datetime(2026, 1, 15, tzinfo=timezone.utc)) == "EST"
assert f.tz_label(None) == "UTC"
names = f.zone_names()
assert "America/New_York" in names and "Europe/London" in names and "UTC" in names
assert list(names) == sorted(names)
@pytest.mark.parametrize("value, kind, cls", [
@@ -0,0 +1,82 @@
"""Integration: the zone a user reads times in (milestone 490), against real
Postgres. The browser's zone is saved for an account without one and never
over one set by hand; the Account page shows and saves it, refuses a zone
Steward does not know, and a change reaches the session on the next request.
Requires STEWARD_DATABASE_URL."""
from __future__ import annotations
import asyncio
import os
import time
import uuid
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
def test_the_browser_zone_is_adopted_once_and_the_account_page_changes_it(app):
from steward.models.users import User, UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
uid = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"tz-{tag}", email=f"tz-{tag}@test",
password_hash="x", role=UserRole.viewer, is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role="viewer", username=f"tz-{tag}",
login_at=time.time(), _csrf="test-token")
csrf = {"X-CSRF-Token": "test-token"}
async def zone():
async with app.db_sessionmaker() as s:
return (await s.get(User, uid)).time_zone
out = {}
# No zone yet: every page carries the script that reports the browser's.
out["before"] = await (await client.get("/account/")).get_data(as_text=True)
out["bogus"] = (await client.post("/account/time-zone/adopt", json={"time_zone": "Mars/Olympus"},
headers=csrf)).status_code
out["adopt"] = await (await client.post("/account/time-zone/adopt",
json={"time_zone": "America/New_York"},
headers=csrf)).get_json()
out["after_adopt"] = await zone()
# A second browser elsewhere does not replace it.
out["again"] = await (await client.post("/account/time-zone/adopt",
json={"time_zone": "Europe/Berlin"},
headers=csrf)).get_json()
out["page"] = await (await client.get("/account/")).get_data(as_text=True)
resp = await client.post("/account/", form={"time_zone": "Europe/London"}, headers=csrf)
out["save"] = (resp.status_code, resp.headers.get("Location"))
out["after_save"] = await zone()
resp = await client.post("/account/", form={"time_zone": "Nowhere"}, headers=csrf)
out["refused"] = (resp.status_code, await resp.get_data(as_text=True))
out["after_refused"] = await zone()
async with client.session_transaction() as sess:
out["session_tz"] = sess.get("tz")
return out
out = asyncio.run(_go())
assert "/account/time-zone/adopt" in out["before"]
assert out["bogus"] == 400
assert out["adopt"] == {"adopted": True} and out["after_adopt"] == "America/New_York"
assert out["again"] == {"adopted": False}
page = out["page"]
assert "times show in <strong>America/New_York</strong> (E" in page # EDT or EST
assert '<option value="America/New_York" selected>New York</option>' in page
assert "/account/time-zone/adopt" not in page # nothing left to adopt
assert out["save"] == (302, "/account/") and out["after_save"] == "Europe/London"
status, body = out["refused"]
assert status == 400 and "Nowhere is not a time zone Steward knows." in body
assert out["after_refused"] == "Europe/London" and out["session_tz"] == "Europe/London"
+1
View File
@@ -18,6 +18,7 @@ TOP_LEVEL = {
"steward/templates/ansible/index.html",
"steward/templates/audit/list.html",
"steward/templates/ai_access/index.html",
"steward/templates/account/index.html",
"plugins/docker/templates/docker/index.html",
"plugins/snmp/templates/snmp/index.html",
"plugins/traefik/templates/traefik/index.html",