feat(timeline): record a change by hand from the Timeline, a host's page or MCP log_change (#6350)
A change Steward can't see happen (a config edited, a disk swapped) is recorded by whoever made it: what was done, its area, when (in the reader's own zone), the hosts, what it was done to and a detail. Its author or an admin edits or deletes it; recording, editing and deleting are audited and those audit rows stay off the timeline. log_change records one as "<user> via MCP", editable by the token's user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -94,6 +94,12 @@ async def entity_labels(db, items: list[dict]) -> dict[tuple[str, str], dict]:
|
||||
labels[(etype, r.id)] = {
|
||||
"label": run_title(r.playbook_path, scopes.get(r.inventory_scope, r.inventory_scope)),
|
||||
"url": f"/ansible/runs/{r.id}", "gone": False}
|
||||
elif etype == "timeline_entry":
|
||||
from steward.models.timeline import TimelineEntry
|
||||
for row_id, summary in (await db.execute(
|
||||
select(TimelineEntry.id, TimelineEntry.summary)
|
||||
.where(TimelineEntry.id.in_(list(wanted))))).all():
|
||||
labels[(etype, row_id)] = {"label": summary, "url": None, "gone": False}
|
||||
elif etype in _SELF_NAMED:
|
||||
for i in wanted:
|
||||
labels[(etype, i)] = {"label": i, "url": _SELF_NAMED[etype], "gone": False}
|
||||
@@ -206,6 +212,9 @@ ACTIONS = {
|
||||
"syslog.pattern_added": "added syslog pattern",
|
||||
"syslog.pattern_updated": "changed syslog pattern",
|
||||
"syslog.pattern_removed": "removed syslog pattern",
|
||||
"timeline.change_recorded": "recorded a change:",
|
||||
"timeline.change_edited": "edited the recorded change",
|
||||
"timeline.change_deleted": "deleted the recorded change",
|
||||
"backup.made": "sent a backup",
|
||||
"backup.failed": "tried to send a backup, which failed:",
|
||||
"backup.restored": "restored Steward from a backup",
|
||||
@@ -218,7 +227,7 @@ AREAS = (("auth.", "Sign-ins"), ("account.", "Accounts"), ("user.", "Users"), ("
|
||||
("ansible.run", "Playbook runs"), ("ansible.", "Ansible (all)"),
|
||||
("settings.", "Settings"), ("plugin.", "Plugins"), ("api_token.", "AI access"),
|
||||
("docker.", "Docker"), ("snmp.", "SNMP"), ("prometheus.", "Prometheus"),
|
||||
("syslog.", "Syslog"), ("backup.", "Backups"))
|
||||
("syslog.", "Syslog"), ("backup.", "Backups"), ("timeline.", "Timeline"))
|
||||
|
||||
|
||||
def action_text(action: str) -> str:
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
"""The timeline: everything that happened to the infrastructure, in one list."""
|
||||
"""The timeline: everything that happened to the infrastructure, in one
|
||||
list, and changes recorded on it by hand."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from steward.mcp._context import current_caller
|
||||
from steward.models.timeline import TimelineEntry
|
||||
from steward.models.users import UserRole
|
||||
from steward.timeline import catalog, config as tl_config, service as tl
|
||||
from steward.timeline import catalog, changes, config as tl_config, service as tl
|
||||
|
||||
from ._audit import audit_mcp, mcp_username
|
||||
from ._shape import age_s, bounded, clamp_limit, db_session, iso, lookback
|
||||
|
||||
|
||||
def _entry(v: dict) -> dict:
|
||||
e = v["entry"]
|
||||
return {
|
||||
"entry_id": e.id,
|
||||
"at": iso(e.at),
|
||||
"age_s": age_s(e.at),
|
||||
"until": iso(e.until),
|
||||
"type": e.type,
|
||||
"kind": v["kind"],
|
||||
"area": v["area"],
|
||||
"summary": e.summary,
|
||||
"note": v["note"],
|
||||
"hosts": e.hosts,
|
||||
"subject": e.subject,
|
||||
"by": e.actor,
|
||||
"url": v["url"],
|
||||
"details_kept": not v["gone"],
|
||||
"ref": {"type": e.ref_type, "id": e.ref_id} if e.ref_type else None,
|
||||
}
|
||||
|
||||
|
||||
async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str = "",
|
||||
range: str = "7d", include_hidden: bool = False, limit: int = 50) -> dict:
|
||||
"""What happened to the infrastructure, newest first, from every source in
|
||||
@@ -48,25 +74,7 @@ async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str
|
||||
rows = (await db.execute(stmt.order_by(TimelineEntry.at.desc(), TimelineEntry.id.desc())
|
||||
.limit(limit + 1))).scalars().all()
|
||||
items = await tl.views(db, cfg, rows)
|
||||
entries = [
|
||||
{
|
||||
"at": iso(v["entry"].at),
|
||||
"age_s": age_s(v["entry"].at),
|
||||
"until": iso(v["entry"].until),
|
||||
"type": v["entry"].type,
|
||||
"kind": v["kind"],
|
||||
"area": v["area"],
|
||||
"summary": v["entry"].summary,
|
||||
"note": v["note"],
|
||||
"hosts": v["entry"].hosts,
|
||||
"subject": v["entry"].subject,
|
||||
"by": v["entry"].actor,
|
||||
"url": v["url"],
|
||||
"details_kept": not v["gone"],
|
||||
"ref": {"type": v["entry"].ref_type, "id": v["entry"].ref_id} if v["entry"].ref_type else None,
|
||||
}
|
||||
for v in items
|
||||
]
|
||||
entries = [_entry(v) for v in items]
|
||||
out = {"range": key, **bounded(entries, limit, key="entries"),
|
||||
"areas": dict(cfg.labels), "types": sorted(catalog.TYPES)}
|
||||
if out["truncated"]:
|
||||
@@ -76,5 +84,67 @@ async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str
|
||||
return out
|
||||
|
||||
|
||||
def _area(cfg, area: str) -> str:
|
||||
"""An area by its key or its label, as Settings → Timeline names it."""
|
||||
want = (area or "").strip().lower()
|
||||
for key, label in cfg.areas:
|
||||
if want in (key, label.lower()):
|
||||
return key
|
||||
raise ValueError(f"area {area!r} is not one of {', '.join(cfg.labels)} (get_timeline lists them).")
|
||||
|
||||
|
||||
async def log_change(summary: str, area: str, hosts: list[str] | str | None = None, subject: str = "",
|
||||
detail: str = "", at: str = "") -> dict:
|
||||
"""Record on the timeline a change Steward can't see happen: a config
|
||||
edited by hand, a package installed, a disk swapped, a service moved, a
|
||||
setting changed in another system. Record one whenever you, or the person
|
||||
you work with, change the infrastructure outside Steward's own tools, so
|
||||
"what changed on this host lately?" has the answer later. Playbook runs,
|
||||
maintenance, deploys and Steward's own settings are recorded already.
|
||||
|
||||
summary: what was done, one line (up to 300 characters), e.g. "Moved
|
||||
Postgres data to the new SSD".
|
||||
area: what part of the infrastructure it is about, by key or label:
|
||||
service, host_os, hardware, storage, network, security, monitoring, as
|
||||
Settings → Timeline names them (get_timeline's `areas`).
|
||||
hosts: the host(s) it was done on, by exact name (list_hosts); none for a
|
||||
change not on any one host.
|
||||
subject: what it was done to, when that is not a host: a container, a
|
||||
service, a file ("postgres", "/etc/fstab").
|
||||
detail: why and how, and anything worth knowing if it goes wrong (up to
|
||||
4000 characters).
|
||||
at: when it was done, ISO-8601 (a time with no zone is UTC); default now.
|
||||
|
||||
Shown as by "<you> via MCP"; the person whose token this is can edit or
|
||||
delete it from the Timeline page. Recorded in the audit log.
|
||||
"""
|
||||
if isinstance(hosts, str):
|
||||
hosts = hosts.split(",")
|
||||
when = None
|
||||
if (at or "").strip():
|
||||
try:
|
||||
when = datetime.fromisoformat(at.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise ValueError(f"at {at!r} is not an ISO-8601 time, e.g. 2026-10-06T22:00:00Z.") from None
|
||||
when = when if when.tzinfo else when.replace(tzinfo=timezone.utc)
|
||||
caller = current_caller()
|
||||
async with db_session() as db:
|
||||
async with db.begin():
|
||||
cfg = await tl_config.load(db)
|
||||
try:
|
||||
f = await changes.fields(db, cfg, summary=summary, area=_area(cfg, area), hosts=hosts or [],
|
||||
subject=subject, note=detail, at=when)
|
||||
except ValueError as exc:
|
||||
if "No host is named" in str(exc):
|
||||
raise ValueError(f"{exc} list_hosts gives the names.") from None
|
||||
raise
|
||||
entry = changes.log(db, f, author_id=caller.user_id, actor=mcp_username(caller.username))
|
||||
[v] = await tl.views(db, cfg, [entry])
|
||||
await audit_mcp("timeline.change_recorded", "timeline_entry", entry.id,
|
||||
{"name": entry.summary, "hosts": entry.hosts, "area": entry.area})
|
||||
return _entry(v)
|
||||
|
||||
|
||||
def register(register_tool) -> None:
|
||||
register_tool(get_timeline, role=UserRole.viewer, read_only=True)
|
||||
register_tool(log_change, role=UserRole.operator)
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
{# One timeline entry's parts, shared by the Timeline page and a host's card.
|
||||
`item` is a timeline.service.view() row. #}
|
||||
|
||||
{# What happened: its kind, the sentence, a note, and the full record while it is kept. #}
|
||||
{% macro entry_what(item) -%}
|
||||
{# What happened: its kind, the sentence, a note, and the full record while it
|
||||
is kept; a change the reader recorded (or any, for an admin) can be edited,
|
||||
returning to `back`. #}
|
||||
{% macro entry_what(item, back="/timeline/") -%}
|
||||
{%- set e = item.entry -%}
|
||||
<div>
|
||||
{%- if item.kind_label %}<span class="tone-dim">{{ item.kind_label }} · </span>{% endif -%}
|
||||
{{ e.summary }}
|
||||
</div>
|
||||
{%- if item.note %}<div class="field-hint">{{ item.note }}</div>{% endif %}
|
||||
{%- if item.note %}<div class="field-hint" style="white-space:pre-line;">{{ item.note }}</div>{% endif %}
|
||||
{%- if item.url %}<div class="field-hint"><a href="{{ item.url }}">Details</a></div>
|
||||
{%- elif item.gone %}<div class="field-hint">Details no longer kept</div>{% endif %}
|
||||
{%- if item.editable %}<div class="field-hint"><a href="/timeline/changes/{{ e.id }}/edit?back={{ back|urlencode }}">Edit</a></div>{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{# Where: the hosts it happened on, each linked to its timeline, and what it happened to. #}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
<section class="card" aria-labelledby="host-history">
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:1rem;">
|
||||
<h3 id="host-history" class="section-title">History</h3>
|
||||
<a class="field-hint" href="/timeline/?host={{ host.name|urlencode }}">All of {{ host.name }}'s history</a>
|
||||
<span class="field-hint">
|
||||
{%- if session.user_role in ('operator', 'admin') %}<a href="/timeline/changes/new?host={{ host.name|urlencode }}&back={{ ('/hosts/' ~ host.id)|urlencode }}">Record a change</a> · {% endif -%}
|
||||
<a href="/timeline/?host={{ host.name|urlencode }}">All of {{ host.name }}'s history</a>
|
||||
</span>
|
||||
</div>
|
||||
{% if items %}
|
||||
<table class="table">
|
||||
@@ -13,7 +16,7 @@
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td class="nowrap" style="width:1%;">{{ item.entry.at|when }}</td>
|
||||
<td>{{ entry_what(item) }}</td>
|
||||
<td>{{ entry_what(item, '/hosts/' ~ host.id) }}</td>
|
||||
<td class="nowrap" style="width:1%;">{{ item.entry.actor }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% if item.opens_group %}<tr class="table-divider"><th colspan="5" scope="rowgroup">{{ item.group }}</th></tr>{% endif %}
|
||||
<tr>
|
||||
<td class="nowrap">{{ e.at|when }}</td>
|
||||
<td>{{ entry_what(item) }}</td>
|
||||
<td>{{ entry_what(item, back) }}</td>
|
||||
<td>{{ entry_where(e) }}</td>
|
||||
<td class="nowrap">{{ e.actor }}</td>
|
||||
<td class="nowrap"><span class="chip">{{ item.area_label }}</span></td>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{# timeline/change_form.html — record a change by hand, or edit one (timeline.changes):
|
||||
what was done, the area it is about, the hosts, when, and any detail. Saving
|
||||
returns to `back`, the page the form was opened from. #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs, page_header %}
|
||||
{% set title = "Edit a recorded change" if entry else "Record a change" %}
|
||||
{% block title %}{{ title }} — Timeline — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Timeline", "/timeline/"), (title, "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ page_header(title, "Something Steward can't see happen: a config edited, a disk swapped, a service moved.") }}
|
||||
|
||||
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
|
||||
|
||||
<form method="post" action="{{ '/timeline/changes/' ~ entry.id if entry else '/timeline/changes' }}" class="form-page">{{ csrf_field() }}
|
||||
<input type="hidden" name="back" value="{{ back }}">
|
||||
<section class="card form-section" aria-labelledby="chg-what">
|
||||
<h2 id="chg-what" class="section-title">The change</h2>
|
||||
<div class="field">
|
||||
<label class="field-label" for="chg-summary">What was done</label>
|
||||
<input class="input" id="chg-summary" type="text" name="summary" value="{{ values.summary }}"
|
||||
maxlength="300" required autofocus placeholder="e.g. Moved Postgres data to the new SSD">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="chg-area">Area</label>
|
||||
<select class="input" id="chg-area" name="area" required>
|
||||
<option value="">— pick one —</option>
|
||||
{% for key, label in areas %}<option value="{{ key }}"{% if values.area == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<span class="field-hint">What part of the infrastructure it is about.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="chg-at">When ({{ tz_label() }})</label>
|
||||
<input class="input" id="chg-at" type="datetime-local" name="at" value="{{ values.at }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="chg-subject">What it was done to <span class="tone-dim">(optional)</span></label>
|
||||
<input class="input" id="chg-subject" type="text" name="subject" value="{{ values.subject }}"
|
||||
maxlength="255" placeholder="e.g. postgres, the core switch, /etc/fstab">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="chg-note">Detail <span class="tone-dim">(optional)</span></label>
|
||||
<textarea class="input" id="chg-note" name="note" rows="4" maxlength="4000"
|
||||
placeholder="Why, how, and anything to know if it goes wrong.">{{ values.note }}</textarea>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card form-section" aria-labelledby="chg-hosts">
|
||||
<h2 id="chg-hosts" class="section-title">Hosts</h2>
|
||||
<p class="field-hint">The change shows in each ticked host's history. Tick none for a change not on any one host.</p>
|
||||
{% if hosts %}
|
||||
<div class="check-list">
|
||||
{% for h in hosts %}
|
||||
<label class="field-check">
|
||||
<input type="checkbox" name="hosts" value="{{ h }}"{% if h in values.hosts %} checked{% endif %}>
|
||||
<span>{{ h }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="tone-muted">No hosts yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn">{{ "Save" if entry else "Record the change" }}</button>
|
||||
<a href="{{ back }}" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if entry %}
|
||||
<section class="card danger-zone" aria-labelledby="chg-delete">
|
||||
<div>
|
||||
<h2 id="chg-delete" class="section-title">Delete this change</h2>
|
||||
<p class="field-hint">Recorded by {{ entry.actor }}. Deleting takes it off the timeline; the audit log keeps that it was.</p>
|
||||
</div>
|
||||
<form method="post" action="/timeline/changes/{{ entry.id }}/delete"
|
||||
data-confirm="Delete “{{ entry.summary }}” from the timeline?">{{ csrf_field() }}
|
||||
<input type="hidden" name="back" value="{{ back }}">
|
||||
<button class="btn btn-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -8,6 +8,7 @@
|
||||
{% block title %}Timeline — Steward{% endblock %}
|
||||
{% block content %}
|
||||
{% call page_header("Timeline", "What happened, newest first: alerts, playbook runs and changes, on every host.") %}
|
||||
{% if session.user_role in ('operator', 'admin') %}<a class="btn btn-sm" href="/timeline/changes/new?{{ ({'host': filters.host, 'back': back} if filters.host else {'back': back})|urlencode }}">Record a change</a>{% endif %}
|
||||
{% if session.user_role == 'admin' %}<a class="btn btn-ghost btn-sm" href="/settings/timeline/">Areas and types</a>{% endif %}
|
||||
{% endcall %}
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@ for _t in (
|
||||
"What an alert rule watches stopped reporting."),
|
||||
("alert.resumed", "Readings resumed", "alert", "monitoring", True,
|
||||
"What an alert rule watches reported again after a silence."),
|
||||
("change.recorded", "Change recorded", "change", "service", True,
|
||||
"A change someone recorded by hand, on the Timeline or a host's page, or through MCP."),
|
||||
("ansible.changed", "Playbook changed something", "automation", "host_os", True,
|
||||
"A playbook run finished and changed at least one host."),
|
||||
("ansible.unchanged", "Playbook changed nothing", "automation", "host_os", False,
|
||||
@@ -145,6 +147,8 @@ _AUDIT_TYPES = (
|
||||
("account.time_zone", None), ("account.email", None),
|
||||
("host_agent.maintenance_plan", "steward.config"), ("host_agent.maintenance", None),
|
||||
("docker.prune.", None),
|
||||
# A change recorded by hand is on the timeline as itself (timeline.changes).
|
||||
("timeline.change", None),
|
||||
("auth.login", "steward.signin"), ("auth.setup", "steward.config"),
|
||||
("account.", "steward.access"), ("user.", "steward.access"), ("api_token.", "steward.access"),
|
||||
("backup.", "steward.backup"),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Changes a person or an agent records by hand: a config edited on a host, a
|
||||
disk swapped, a service moved. Steward can't see these happen, so whoever
|
||||
made the change writes it down, from the Timeline page, a host's page, or
|
||||
the log_change MCP tool.
|
||||
|
||||
Each is one entry of type change.recorded, sorted under the area its author
|
||||
picked. Its author can edit or delete it, and so can an admin; the entry
|
||||
keeps the author's user id in its detail for that. Writing, editing and
|
||||
deleting are audited, but the audit rows stay off the timeline, which holds
|
||||
the change itself (catalog.audit_type).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from .config import Config
|
||||
from .record import record
|
||||
from steward.models.timeline import TimelineEntry
|
||||
|
||||
TYPE = "change.recorded"
|
||||
SUMMARY_MAX = 300
|
||||
NOTE_MAX = 4000
|
||||
SUBJECT_MAX = 255
|
||||
# A change is recorded once it is made; a clock a little ahead is allowed for.
|
||||
_AHEAD = timedelta(minutes=5)
|
||||
|
||||
|
||||
async def fields(db, cfg: Config, *, summary: str, area: str, hosts=(), subject: str = "",
|
||||
note: str = "", at: datetime | None = None, now: datetime | None = None) -> dict:
|
||||
"""The entry's fields, checked: a summary, an area Settings → Timeline
|
||||
lists, hosts Steward knows by name, and a time not in the future. Raises
|
||||
ValueError saying what to fix."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
summary = " ".join((summary or "").split())
|
||||
if not summary:
|
||||
raise ValueError("Say what was done.")
|
||||
if len(summary) > SUMMARY_MAX:
|
||||
raise ValueError(f"Keep what was done to {SUMMARY_MAX} characters; put the rest in the detail.")
|
||||
if area not in cfg.labels:
|
||||
raise ValueError(f"Pick an area: {', '.join(cfg.labels.values())}.")
|
||||
from steward.models.hosts import Host
|
||||
wanted = sorted({h.strip() for h in hosts if h and h.strip()})
|
||||
known = set((await db.execute(select(Host.name).where(Host.name.in_(wanted)))).scalars()) if wanted else set()
|
||||
unknown = [h for h in wanted if h not in known]
|
||||
if unknown:
|
||||
raise ValueError(f"No host is named {', '.join(unknown)}.")
|
||||
note = (note or "").strip()
|
||||
if len(note) > NOTE_MAX:
|
||||
raise ValueError(f"Keep the detail to {NOTE_MAX} characters.")
|
||||
at = at or now
|
||||
if at > now + _AHEAD:
|
||||
raise ValueError("A change is recorded once it is made; pick a time that has passed.")
|
||||
return {"summary": summary, "area": area, "hosts": wanted,
|
||||
"subject": (subject or "").strip()[:SUBJECT_MAX] or None, "note": note or None, "at": at}
|
||||
|
||||
|
||||
def log(db, f: dict, *, author_id: str | None, actor: str) -> TimelineEntry:
|
||||
"""Add the entry `fields` checked, written by `actor` ("<user>" or
|
||||
"<user> via MCP")."""
|
||||
return record(db, type=TYPE, at=f["at"], summary=f["summary"], area=f["area"], hosts=f["hosts"],
|
||||
subject=f["subject"], actor=actor, detail=_detail(f, author_id))
|
||||
|
||||
|
||||
def edit(entry: TimelineEntry, f: dict) -> None:
|
||||
author_id = (entry.detail or {}).get("author_id")
|
||||
entry.at, entry.summary, entry.area = f["at"], f["summary"], f["area"]
|
||||
entry.hosts, entry.subject = f["hosts"], f["subject"]
|
||||
entry.detail = _detail(f, author_id)
|
||||
|
||||
|
||||
def _detail(f: dict, author_id: str | None) -> dict:
|
||||
return {"note": f["note"], "author_id": author_id}
|
||||
|
||||
|
||||
def can_edit(entry: TimelineEntry, user_id: str | None, role: str | None) -> bool:
|
||||
"""Whether this reader may edit or delete `entry`: a recorded change,
|
||||
by its author or an admin."""
|
||||
if entry.type != TYPE or not user_id:
|
||||
return False
|
||||
return role == "admin" or (entry.detail or {}).get("author_id") == user_id
|
||||
|
||||
|
||||
async def get(db, entry_id: str) -> TimelineEntry | None:
|
||||
entry = await db.get(TimelineEntry, entry_id)
|
||||
return entry if entry is not None and entry.type == TYPE else None
|
||||
+149
-6
@@ -1,17 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, current_app, redirect, render_template, request, session, url_for
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from quart import Blueprint, abort, 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.auth.middleware import require_role, safe_next_url
|
||||
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
|
||||
from steward.timeline import catalog, changes, config as tl_config, service as tl
|
||||
|
||||
timeline_bp = Blueprint("timeline", __name__)
|
||||
|
||||
@@ -34,14 +37,15 @@ async def timeline():
|
||||
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)
|
||||
items = _editable(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,
|
||||
back = "/timeline/" + ("?" + urlencode(keep) if keep else "")
|
||||
ctx = {"items": items, "after": after, "last_group": last_group, "filters": f, "keep": keep, "back": back,
|
||||
"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"):
|
||||
@@ -58,10 +62,149 @@ async def host_card(host_id: str):
|
||||
if host is None:
|
||||
return "", 404
|
||||
cfg = await tl_config.load(db)
|
||||
items = await tl.recent_for_host(db, cfg, host.name)
|
||||
items = _editable(await tl.recent_for_host(db, cfg, host.name))
|
||||
return await render_template("timeline/_host_card.html", host=host, items=items)
|
||||
|
||||
|
||||
def _editable(items: list[dict]) -> list[dict]:
|
||||
"""Mark the entries this reader may edit: changes they recorded, or any as an admin."""
|
||||
for item in items:
|
||||
item["editable"] = changes.can_edit(item["entry"], session.get("user_id"), session.get("user_role"))
|
||||
return items
|
||||
|
||||
|
||||
# ── Changes recorded by hand ────────────────────────────────────────────────
|
||||
|
||||
def _local(value: str | None) -> datetime | None:
|
||||
"""A datetime-local field, in the reader's own zone."""
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
local = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
raise ValueError(f"{value!r} is not a date and time.") from None
|
||||
return local.replace(tzinfo=fmt.zone(session.get("tz"))).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _as_local(when: datetime) -> str:
|
||||
return when.astimezone(fmt.zone(session.get("tz"))).strftime("%Y-%m-%dT%H:%M")
|
||||
|
||||
|
||||
async def _change_page(db, cfg, *, values: dict, entry=None, back: str, error: str | None = None,
|
||||
status: int = 200):
|
||||
from steward.models.hosts import Host
|
||||
hosts = list((await db.execute(select(Host.name).order_by(Host.name))).scalars())
|
||||
# A host named by an entry and since removed stays ticked, so a save keeps it.
|
||||
hosts += [h for h in values["hosts"] if h not in hosts]
|
||||
return await render_template("timeline/change_form.html", values=values, entry=entry, back=back,
|
||||
error=error, hosts=hosts, areas=cfg.areas), status
|
||||
|
||||
|
||||
def _back(raw: str | None) -> str:
|
||||
"""Where saving returns: the page the form was opened from, else the timeline."""
|
||||
return safe_next_url(raw) or "/timeline/"
|
||||
|
||||
|
||||
async def _checked(db, cfg) -> tuple[dict | None, dict, str, str | None]:
|
||||
"""(fields, values, back, error) from the posted form: the fields
|
||||
changes.fields checked, or None and why not, with the form's own values
|
||||
to show again."""
|
||||
form = await request.form
|
||||
values = {"summary": form.get("summary", ""), "area": form.get("area", ""),
|
||||
"hosts": form.getlist("hosts"), "subject": form.get("subject", ""),
|
||||
"note": form.get("note", ""), "at": form.get("at", "")}
|
||||
back = _back(form.get("back"))
|
||||
try:
|
||||
f = await changes.fields(db, cfg, summary=values["summary"], area=values["area"],
|
||||
hosts=values["hosts"], subject=values["subject"],
|
||||
note=values["note"], at=_local(values["at"]))
|
||||
except ValueError as exc:
|
||||
return None, values, back, str(exc)
|
||||
return f, values, back, None
|
||||
|
||||
|
||||
@timeline_bp.get("/timeline/changes/new")
|
||||
@require_role(UserRole.operator)
|
||||
async def new_change():
|
||||
"""The form for recording a change; ?host= ticks a host, ?back= is where saving returns."""
|
||||
host = request.args.get("host", "").strip()
|
||||
values = {"summary": "", "area": "", "hosts": [host] if host else [], "subject": "", "note": "",
|
||||
"at": _as_local(datetime.now(timezone.utc))}
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
cfg = await tl_config.load(db)
|
||||
return await _change_page(db, cfg, values=values, back=_back(request.args.get("back")))
|
||||
|
||||
|
||||
@timeline_bp.post("/timeline/changes")
|
||||
@require_role(UserRole.operator)
|
||||
async def record_change():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
cfg = await tl_config.load(db)
|
||||
f, values, back, error = await _checked(db, cfg)
|
||||
if error:
|
||||
return await _change_page(db, cfg, values=values, back=back, error=error, status=400)
|
||||
entry = changes.log(db, f, author_id=session.get("user_id"), actor=session.get("username", ""))
|
||||
await audit_request("timeline.change_recorded", entity_type="timeline_entry", entity_id=entry.id,
|
||||
detail={"name": entry.summary, "hosts": entry.hosts, "area": entry.area})
|
||||
return redirect(back)
|
||||
|
||||
|
||||
async def _own(db, entry_id: str):
|
||||
"""The recorded change `entry_id`, when this reader may edit it; else 404 or 403."""
|
||||
entry = await changes.get(db, entry_id)
|
||||
if entry is None:
|
||||
abort(404)
|
||||
if not changes.can_edit(entry, session.get("user_id"), session.get("user_role")):
|
||||
abort(403)
|
||||
return entry
|
||||
|
||||
|
||||
@timeline_bp.get("/timeline/changes/<entry_id>/edit")
|
||||
@require_role(UserRole.operator)
|
||||
async def edit_change(entry_id: str):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
entry = await _own(db, entry_id)
|
||||
cfg = await tl_config.load(db)
|
||||
values = {"summary": entry.summary, "area": entry.area, "hosts": list(entry.hosts),
|
||||
"subject": entry.subject or "", "note": (entry.detail or {}).get("note") or "",
|
||||
"at": _as_local(entry.at)}
|
||||
return await _change_page(db, cfg, values=values, entry=entry,
|
||||
back=_back(request.args.get("back")))
|
||||
|
||||
|
||||
@timeline_bp.post("/timeline/changes/<entry_id>")
|
||||
@require_role(UserRole.operator)
|
||||
async def save_change(entry_id: str):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
entry = await _own(db, entry_id)
|
||||
cfg = await tl_config.load(db)
|
||||
f, values, back, error = await _checked(db, cfg)
|
||||
if error:
|
||||
return await _change_page(db, cfg, values=values, entry=entry, back=back,
|
||||
error=error, status=400)
|
||||
changes.edit(entry, f)
|
||||
await audit_request("timeline.change_edited", entity_type="timeline_entry", entity_id=entry_id,
|
||||
detail={"name": f["summary"]})
|
||||
return redirect(back)
|
||||
|
||||
|
||||
@timeline_bp.post("/timeline/changes/<entry_id>/delete")
|
||||
@require_role(UserRole.operator)
|
||||
async def delete_change(entry_id: str):
|
||||
form = await request.form
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
entry = await _own(db, entry_id)
|
||||
summary = entry.summary
|
||||
await db.delete(entry)
|
||||
await audit_request("timeline.change_deleted", entity_type="timeline_entry", entity_id=entry_id,
|
||||
detail={"name": summary})
|
||||
return redirect(_back(form.get("back")))
|
||||
|
||||
|
||||
@timeline_bp.get("/settings/timeline/")
|
||||
@require_role(UserRole.admin)
|
||||
async def settings_page():
|
||||
|
||||
Reference in New Issue
Block a user