"""The Tickets pages: the list (open first), one ticket with its log, the form that opens or edits one, and the card a host's or a resource's page loads with its open tickets. Writes are for operators; each is audited.""" from __future__ import annotations from quart import Blueprint, abort, current_app, redirect, render_template, request, session from steward.auth.middleware import require_role, safe_next_url from steward.core.audit import audit_request from steward.models.users import UserRole from steward.tickets import service as tk from steward.timeline import config as tl_config tickets_bp = Blueprint("tickets", __name__) def _actor() -> str: return session.get("username", "") @tickets_bp.get("/tickets/") @require_role(UserRole.viewer) async def list_tickets(): status = request.args.get("status", tk.OPEN) status = status if status in (*tk.STATUSES, "all") else tk.OPEN host = request.args.get("host", "").strip() async with current_app.db_sessionmaker() as db: cfg = await tl_config.load(db) tickets = list((await db.execute(tk.query(status=status, host=host).limit(500))).scalars()) counts = await tk.comment_counts(db, [t.id for t in tickets]) open_n = await tk.open_count(db) return await render_template("tickets/list.html", tickets=tickets, counts=counts, status=status, host=host, open_count=open_n, labels=cfg.labels) def _parse_links(values) -> list[tuple[str, str]]: out = [] for v in values: ref_type, _, ref_id = (v or "").partition(":") if ref_type and ref_id: out.append((ref_type, ref_id)) return out async def _form_page(db, cfg, *, values: dict, links, ticket=None, back: str, error: str | None = None, status: int = 200): from sqlalchemy import select from steward.models.hosts import Host hosts = list((await db.execute(select(Host.name).order_by(Host.name))).scalars()) hosts += [h for h in values["hosts"] if h not in hosts] return await render_template("tickets/form.html", values=values, ticket=ticket, back=back, error=error, hosts=hosts, areas=cfg.areas, links=await tk.link_views(db, links)), status @tickets_bp.get("/tickets/new") @require_role(UserRole.operator) async def new_ticket(): """The form for opening a ticket; ?rule=, ?anomaly=, ?host= or ?subject= fill it from what it is opened on, ?back= is where Cancel goes.""" a = request.args async with current_app.db_sessionmaker() as db: cfg = await tl_config.load(db) values, links = await tk.draft(db, rule_id=a.get("rule", ""), anomaly_id=a.get("anomaly", ""), host=a.get("host", "").strip(), subject=a.get("subject", "").strip()) return await _form_page(db, cfg, values=values, links=links, back=safe_next_url(a.get("back")) or "/tickets/") def _values(form) -> dict: return {"title": form.get("title", ""), "area": form.get("area", ""), "body": form.get("body", ""), "hosts": form.getlist("hosts"), "subject": form.get("subject", "")} async def _checked(db, cfg, values) -> tuple[dict | None, str | None]: try: return await tk.fields(db, cfg, title=values["title"], area=values["area"], body=values["body"], hosts=values["hosts"], subject=values["subject"]), None except ValueError as exc: return None, str(exc) @tickets_bp.post("/tickets/") @require_role(UserRole.operator) async def open_ticket(): form = await request.form values = _values(form) back = safe_next_url(form.get("back")) or "/tickets/" async with current_app.db_sessionmaker() as db: async with db.begin(): cfg = await tl_config.load(db) links = _parse_links(form.getlist("link")) f, error = await _checked(db, cfg, values) if error is None: try: links = await tk.check_links(db, links) except ValueError as exc: error = str(exc) if error: return await _form_page(db, cfg, values=values, links=links, back=back, error=error, status=400) t = await tk.open_ticket(db, f, actor=_actor(), author_id=session.get("user_id"), links=links) await audit_request("ticket.opened", entity_type="ticket", entity_id=str(t.id), detail={"name": t.title, "hosts": t.hosts, "links": [f"{r}:{i}" for r, i in links]}) return redirect(f"/tickets/{t.id}") async def _ticket(db, number: int): t = await tk.get(db, number) if t is None: abort(404) return t @tickets_bp.get("/tickets/") @require_role(UserRole.viewer) async def ticket_page(number: int): async with current_app.db_sessionmaker() as db: t = await _ticket(db, number) cfg = await tl_config.load(db) log = await tk.notes(db, t.id) links = await tk.link_views(db, await tk.links_of(db, t.id)) return await render_template("tickets/ticket.html", t=t, log=log, links=links, area_label=cfg.area_label(cfg.follow(t.area))) @tickets_bp.get("/tickets//edit") @require_role(UserRole.operator) async def edit_ticket(number: int): async with current_app.db_sessionmaker() as db: t = await _ticket(db, number) cfg = await tl_config.load(db) values = {"title": t.title, "area": t.area, "body": t.body or "", "hosts": list(t.hosts), "subject": t.subject or ""} return await _form_page(db, cfg, values=values, links=await tk.links_of(db, t.id), ticket=t, back=f"/tickets/{t.id}") @tickets_bp.post("/tickets/") @require_role(UserRole.operator) async def save_ticket(number: int): """Save an edit. Each link the form lists is kept while its box stays ticked; an unticked one is unlinked.""" form = await request.form values = _values(form) async with current_app.db_sessionmaker() as db: async with db.begin(): t = await _ticket(db, number) cfg = await tl_config.load(db) have = await tk.links_of(db, t.id) keep = set(_parse_links(form.getlist("link"))) f, error = await _checked(db, cfg, values) if error: return await _form_page(db, cfg, values=values, links=have, ticket=t, back=f"/tickets/{t.id}", error=error, status=400) changed = await tk.edit(db, t, f, actor=_actor(), remove_links=[r for r in have if r not in keep]) if changed: await audit_request("ticket.edited", entity_type="ticket", entity_id=str(number), detail={"name": f["title"], "changed": changed}) return redirect(f"/tickets/{number}") async def _act(number: int, action: str, fn): """Comment, resolve or reopen from the ticket page: back to it, or the page again with why it was refused.""" form = await request.form text = form.get("text", "") async with current_app.db_sessionmaker() as db: async with db.begin(): t = await _ticket(db, number) try: await fn(db, t, text, actor=_actor()) except ValueError as exc: error, title = str(exc), t.title else: error, title = None, t.title if error: cfg = await tl_config.load(db) log = await tk.notes(db, t.id) links = await tk.link_views(db, await tk.links_of(db, t.id)) return await render_template("tickets/ticket.html", t=t, log=log, links=links, error=error, text=text, area_label=cfg.area_label(cfg.follow(t.area))), 400 await audit_request(action, entity_type="ticket", entity_id=str(number), detail={"name": title}) return redirect(f"/tickets/{number}") @tickets_bp.post("/tickets//comment") @require_role(UserRole.operator) async def comment_ticket(number: int): return await _act(number, "ticket.commented", tk.comment) @tickets_bp.post("/tickets//resolve") @require_role(UserRole.operator) async def resolve_ticket(number: int): return await _act(number, "ticket.resolved", tk.resolve) @tickets_bp.post("/tickets//reopen") @require_role(UserRole.operator) async def reopen_ticket(number: int): return await _act(number, "ticket.reopened", tk.reopen) @tickets_bp.get("/tickets/card") @require_role(UserRole.viewer) async def card(): """The open tickets a page lists (?host= or ?subject=), loaded into a host's, a monitor's or a container's page; ?back= returns there.""" host = request.args.get("host", "").strip() subject = request.args.get("subject", "").strip() async with current_app.db_sessionmaker() as db: tickets = await tk.for_page(db, host=host, subject=subject) return await render_template("tickets/_card.html", tickets=tickets, host=host, subject=subject, back=safe_next_url(request.args.get("back")) or "/tickets/")