diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 1e3ec37..f5700b6 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 62b906c..dd6cdef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/steward/account/__init__.py b/steward/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/steward/account/routes.py b/steward/account/routes.py new file mode 100644 index 0000000..86dacbe --- /dev/null +++ b/steward/account/routes.py @@ -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} diff --git a/steward/app.py b/steward/app.py index 4bd99ef..3143b5a 100644 --- a/steward/app.py +++ b/steward/app.py @@ -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. diff --git a/steward/audit/service.py b/steward/audit/service.py index 66cf5bc..f647f4c 100644 --- a/steward/audit/service.py +++ b/steward/audit/service.py @@ -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"), diff --git a/steward/auth/middleware.py b/steward/auth/middleware.py index f0842d0..48456cd 100644 --- a/steward/auth/middleware.py +++ b/steward/auth/middleware.py @@ -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: diff --git a/steward/core/formatting.py b/steward/core/formatting.py index 5955b6f..d5738a0 100644 --- a/steward/core/formatting.py +++ b/steward/core/formatting.py @@ -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('').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