from __future__ import annotations from quart import Blueprint, current_app, redirect, render_template, request, session, url_for from sqlalchemy import select from steward.audit.service import mark_day_groups from steward.auth.middleware import require_role from steward.core import formatting as fmt from steward.core.audit import audit_request from steward.core.paging import keyset, next_page from steward.models.hosts import Host from steward.models.timeline import TimelineEntry from steward.models.users import UserRole from steward.timeline import catalog, config as tl_config, service as tl timeline_bp = Blueprint("timeline", __name__) def _filters() -> dict: a = request.args return {"host": a.get("host", "").strip(), "area": a.get("area", "").strip(), "kind": a.get("kind", "").strip(), "hidden": a.get("hidden") == "1"} @timeline_bp.get("/timeline/") @require_role(UserRole.viewer) async def timeline(): """Everything that happened, newest first, a page at a time as the reader scrolls; ?before= is the next page's rows alone (core.paging).""" f = _filters() async with current_app.db_sessionmaker() as db: cfg = await tl_config.load(db) stmt = tl.query(cfg, host=f["host"], area=f["area"], kind=f["kind"], include_hidden=f["hidden"]) stmt = keyset(stmt, TimelineEntry.at, TimelineEntry.id, request.args.get("before")) rows, after = next_page((await db.execute(stmt)).scalars().all(), lambda e: (e.at, e.id)) items = await tl.views(db, cfg, rows) hosts = [] if request.args.get("before") else \ list((await db.execute(select(Host.name).order_by(Host.name))).scalars()) last_group = mark_day_groups(items, fmt.zone(session.get("tz")), request.args.get("group", ""), at=lambda item: item["entry"].at) keep = {k: v for k, v in (("host", f["host"]), ("area", f["area"]), ("kind", f["kind"]), ("hidden", "1" if f["hidden"] else "")) if v} ctx = {"items": items, "after": after, "last_group": last_group, "filters": f, "keep": keep, "areas": cfg.areas, "kinds": catalog.KINDS, "hosts": hosts if not f["host"] or f["host"] in hosts else [f["host"], *hosts]} if request.args.get("before"): return await render_template("timeline/_rows.html", **ctx) return await render_template("timeline/list.html", **ctx) @timeline_bp.get("/timeline/host/") @require_role(UserRole.viewer) async def host_card(host_id: str): """A host's newest entries, for its page (loaded into it).""" async with current_app.db_sessionmaker() as db: host = await db.get(Host, host_id) if host is None: return "", 404 cfg = await tl_config.load(db) items = await tl.recent_for_host(db, cfg, host.name) return await render_template("timeline/_host_card.html", host=host, items=items) @timeline_bp.get("/settings/timeline/") @require_role(UserRole.admin) async def settings_page(): async with current_app.db_sessionmaker() as db: cfg = await tl_config.load(db) from steward.core.settings import get_setting retention = int(await get_setting(db, "timeline.retention_days") or 0) return await render_template("settings/timeline.html", **_settings_ctx(cfg, retention)) def _settings_ctx(cfg, retention: int, error: str | None = None) -> dict: by_kind = {k: [t for t in catalog.TYPES.values() if t.kind == k] for k in catalog.KINDS} return {"cfg": cfg, "retention": retention, "error": error, "kinds": catalog.KINDS, "by_kind": {k: v for k, v in by_kind.items() if v}} @timeline_bp.post("/settings/timeline/") @require_role(UserRole.admin) async def save_settings(): from steward.core.settings import set_setting form = await request.form try: retention = max(0, int(form.get("retention_days") or 0)) except ValueError: retention = None async with current_app.db_sessionmaker() as db: async with db.begin(): cfg = await tl_config.load(db) new, error = tl_config.edited(cfg, form) if error is None and retention is None: error = "Keep for: a whole number of days, or 0 to keep everything." if error is not None: kept = retention if retention is not None else 0 return await render_template("settings/timeline.html", **_settings_ctx(cfg, kept, error)), 400 await tl_config.save(db, new) await set_setting(db, "timeline.retention_days", retention) await audit_request("settings.saved", detail={"section": "timeline"}) return redirect(url_for("timeline.settings_page"))