Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-04-27 07:41:45

0001 """Alarm dashboard + editor views.
0002 
0003 The dashboard lives at /prod/alarms/. It renders:
0004   1. A top summary table (one row per alarm config) — last-N-hours counts,
0005      last-fired time.
0006   2. A per-alarm section for each active config — config metadata + body +
0007      in-window events + [Edit] link.
0008   3. A recent-engine-runs table (for engine health visibility).
0009 
0010 Editor: /prod/alarms/<alarm_entry_id>/edit/ — CodeMirror on the body
0011 (content), form fields for params/recipients/enabled. Autosave
0012 every 10s via POST; version history rendered inline, click to restore.
0013 
0014 Autosave endpoint: POST /prod/alarms/<alarm_entry_id>/autosave/ with JSON
0015 body. Returns {version_num, modified}.
0016 
0017 The pre_save signal on Entry (signals.py) owns version snapshotting; these
0018 views never write EntryVersion directly.
0019 """
0020 from __future__ import annotations
0021 
0022 import importlib
0023 import json
0024 import os
0025 import sys
0026 import time
0027 import traceback
0028 import uuid
0029 from datetime import datetime
0030 from zoneinfo import ZoneInfo
0031 
0032 from django.conf import settings
0033 from django.contrib.auth.decorators import login_required
0034 from django.http import HttpResponse, JsonResponse
0035 from django.shortcuts import render
0036 from django.views.decorators.csrf import csrf_exempt
0037 from django.views.decorators.http import require_POST
0038 
0039 _EASTERN = ZoneInfo('America/New_York')
0040 
0041 
0042 def _to_dt(value):
0043     """Float Unix ts → aware datetime, else pass through."""
0044     if value is None or value == '':
0045         return None
0046     if isinstance(value, (int, float)):
0047         try:
0048             return datetime.fromtimestamp(float(value), tz=_EASTERN)
0049         except (OSError, OverflowError, ValueError):
0050             return None
0051     return value
0052 
0053 
0054 def _recipients_to_text(value):
0055     """Render stored recipients for the textarea verbatim.
0056 
0057     Storage may be str (new — user-typed, preserved) or list[str] (legacy —
0058     pre-change rows). Strings pass through untouched. Lists are rejoined
0059     with ', ' so they render as one line in the editor.
0060     """
0061     if value is None:
0062         return ''
0063     if isinstance(value, str):
0064         return value
0065     if isinstance(value, (list, tuple)):
0066         return ', '.join(str(v) for v in value)
0067     return str(value)
0068 
0069 from . import alarms_data
0070 from .models import Entry, EntryContext, EntryVersion
0071 from .signals import set_changed_by
0072 
0073 
0074 # ── alarm-module loader (for the editor's help panel + test endpoint) ─────
0075 #
0076 # The engine code lives in <repo>/alarms/swf_alarms/. This file lives in
0077 # <repo>/src/remote_app/alarm_views.py. Two directories up from this file
0078 # is the repo root, so the engine package sits at "../../alarms/swf_alarms".
0079 
0080 _ALARMS_PKG_PARENT = os.path.normpath(
0081     os.path.join(os.path.dirname(__file__), '..', '..', 'alarms'))
0082 
0083 
0084 def _ensure_engine_importable() -> None:
0085     if _ALARMS_PKG_PARENT not in sys.path and os.path.isdir(_ALARMS_PKG_PARENT):
0086         sys.path.insert(0, _ALARMS_PKG_PARENT)
0087 
0088 
0089 def _alarm_module(entry_id: str):
0090     _ensure_engine_importable()
0091     name = entry_id[len('alarm_'):] if entry_id.startswith('alarm_') else entry_id
0092     return importlib.import_module(f"swf_alarms.alarms.{name}")
0093 
0094 
0095 def _detection_detail_from_event(content: str) -> str:
0096     """Peel the description+separator off an event's stored content to
0097     leave just the detection detail (what `detect()` put in
0098     `body_context`). Events are persisted with the full single-detection
0099     body, i.e. ``<alarm.content>\\n\\n---\\n\\n<body_context>``."""
0100     if not content:
0101         return ''
0102     marker = '\n\n---\n\n'
0103     if marker in content:
0104         return content.split(marker, 1)[1]
0105     return content
0106 
0107 
0108 def _preview_current_state(entry_id: str, description: str) -> tuple[str, str]:
0109     """Build the (subject, body) email preview for this alarm's CURRENT
0110     state — i.e. what would go out right now if we emailed every
0111     currently-active event as one bundle.
0112 
0113     Matches the engine's `_compose_bundle` layout: description + ``---`` +
0114     a single list of active entities, each with its body_context indented.
0115     Returns ('', '') when nothing is active.
0116     """
0117     active = alarms_data.active_event_rows(entry_id)
0118     n = len(active)
0119     if n == 0:
0120         return ('', '')
0121     subject = f"[{entry_id}] {n} detection(s) currently active"
0122     parts: list[str] = []
0123     desc = (description or '').rstrip()
0124     if desc:
0125         parts.append(desc)
0126         parts.append('')
0127         parts.append('---')
0128         parts.append('')
0129     parts.append(f"CURRENTLY ACTIVE ({n}):")
0130     parts.append('')
0131     for i, ev in enumerate(active, 1):
0132         data = ev.data or {}
0133         subj = data.get('subject') or ev.title or '?'
0134         detail = _detection_detail_from_event(ev.content or '')
0135         parts.append(f"  [{i}] {subj}")
0136         for line in detail.splitlines():
0137             parts.append(f"      {line}")
0138         parts.append('')
0139     body = '\n'.join(parts).rstrip() + '\n'
0140     return (subject, body)
0141 
0142 
0143 def _alarm_params_meta(entry_id: str) -> list[dict]:
0144     """Read the alarm module's PARAMS for the editor help panel.
0145 
0146     Returns a list of {name, type, required, default, description} rows
0147     in declaration order. Returns [] on any import failure — the editor
0148     just hides the help panel.
0149     """
0150     try:
0151         mod = _alarm_module(entry_id)
0152     except Exception:
0153         return []
0154     params = getattr(mod, 'PARAMS', None) or {}
0155     rows: list[dict] = []
0156     for k, v in params.items():
0157         if not isinstance(v, dict):
0158             continue
0159         t = v.get('type')
0160         rows.append({
0161             'name': k,
0162             'type': getattr(t, '__name__', str(t)) if t else '',
0163             'required': bool(v.get('required')),
0164             'default': v.get('default'),
0165             'has_default': 'default' in v,
0166             'description': v.get('description') or '',
0167         })
0168     return rows
0169 
0170 
0171 # ── dashboard ─────────────────────────────────────────────────────────────
0172 
0173 def alarms_dashboard(request):
0174     try:
0175         hours = max(1, int(request.GET.get('hours', 24)))
0176     except (TypeError, ValueError):
0177         hours = 24
0178 
0179     configs = alarms_data.alarm_configs()
0180     quiet = alarms_data.quiet_alarms()
0181 
0182     def _params_table(entry_id: str, params: dict) -> list[dict]:
0183         """Per-alarm param rows for the main-page table.
0184 
0185         One row per param declared in the alarm module's PARAMS, plus
0186         any extra keys stored on the config that the module doesn't
0187         declare (those get a warning description).
0188         """
0189         meta_list = _alarm_params_meta(entry_id)
0190         rows: list[dict] = []
0191         seen: set[str] = set()
0192         for m in meta_list:
0193             k = m['name']
0194             seen.add(k)
0195             rows.append({
0196                 'name': k,
0197                 'value': params.get(k),
0198                 'has_value': k in params,
0199                 'type': m.get('type') or '',
0200                 'required': bool(m.get('required')),
0201                 'default': m.get('default'),
0202                 'has_default': bool(m.get('has_default')),
0203                 'description': m.get('description') or '',
0204             })
0205         for k, v in params.items():
0206             if k in seen:
0207                 continue
0208             rows.append({
0209                 'name': k,
0210                 'value': v,
0211                 'has_value': True,
0212                 'type': '',
0213                 'required': False,
0214                 'default': None,
0215                 'has_default': False,
0216                 'description': '(not declared in module PARAMS)',
0217             })
0218         return rows
0219 
0220     # Per-config: count + last-fired + events-since (reversed chron).
0221     sections = []
0222     summary_rows = []
0223     for cfg in configs:
0224         eid = cfg['entry_id']
0225         count = alarms_data.count_events_since(eid, hours)
0226         last = alarms_data.last_fired(eid)
0227         active = alarms_data.active_event_count(eid)
0228         active_rows = alarms_data.active_events(eid)
0229         is_quiet = eid in quiet
0230         summary_rows.append({
0231             'entry_id': eid,
0232             'name': cfg['name'],
0233             'title': cfg.get('title', ''),
0234             'enabled': cfg['enabled'],
0235             'count': count,
0236             'active': active,
0237             'last_fired': last,
0238             'last_fired_dt': _to_dt(last),
0239             'quiet': is_quiet,
0240         })
0241         preview_subject, preview_body = _preview_current_state(
0242             eid, cfg.get('content') or '')
0243         sections.append({
0244             'config': cfg,
0245             'count': count,
0246             'active': active,
0247             'last_fired': last,
0248             'active_rows': active_rows,
0249             'quiet': is_quiet,
0250             'params_table': _params_table(eid, cfg['params']),
0251             'preview_subject': preview_subject,
0252             'preview_body': preview_body,
0253         })
0254 
0255     # Cron fires */5 — seconds remaining to next 5-min boundary.
0256     cycle_seconds = 300
0257     now = time.time()
0258     next_check_seconds = int(cycle_seconds - (now % cycle_seconds))
0259     built_at_dt = datetime.fromtimestamp(now, tz=_EASTERN)
0260 
0261     return render(request, 'monitor_app/alarms.html', {
0262         'hours': hours,
0263         'summary_rows': summary_rows,
0264         'sections': sections,
0265         'teams': alarms_data.list_teams(),
0266         'health': alarms_data.engine_health(),
0267         'recent_runs': alarms_data.recent_runs(limit=20),
0268         'cycle_seconds': cycle_seconds,
0269         'next_check_seconds': next_check_seconds,
0270         'built_at_dt': built_at_dt,
0271         'auto_refresh_seconds': 10,
0272     })
0273 
0274 
0275 # ── event detail ──────────────────────────────────────────────────────────
0276 
0277 def alarm_run_report(request, run_uuid: str, entry_id: str):
0278     """Show the per-alarm bundle for one engine run.
0279 
0280     This is exactly what WOULD have been emailed for this alarm on this
0281     tick (or was, if emails were on and the bundle was non-empty).
0282     Covers every detection the bundle would have carried — new +
0283     continuing. No emails are involved in rendering this page.
0284     """
0285     try:
0286         run = Entry.objects.get(id=run_uuid, context_id='swf-alarms',
0287                                 kind='engine_run',
0288                                 deleted_at__isnull=True)
0289     except Entry.DoesNotExist:
0290         return HttpResponse(f'Engine run {run_uuid} not found', status=404,
0291                             content_type='text/plain')
0292     per_alarm = (run.data or {}).get('per_alarm') or {}
0293     info = per_alarm.get(entry_id)
0294     if info is None:
0295         return HttpResponse(
0296             f'Alarm {entry_id} did not run in this tick.',
0297             status=404, content_type='text/plain')
0298 
0299     new_ids = info.get('bundle_new_event_ids') or []
0300     ren_ids = info.get('bundle_renotify_event_ids') or []
0301     ev_by_id = {
0302         e.id: e for e in Entry.objects.filter(
0303             id__in=list(new_ids) + list(ren_ids),
0304             context_id='swf-alarms', kind='event')
0305     }
0306     # Preserve ordering from the stored lists.
0307     new_events = [ev_by_id[u] for u in new_ids if u in ev_by_id]
0308     ren_events = [ev_by_id[u] for u in ren_ids if u in ev_by_id]
0309 
0310     # Pull the alarm config so we can show the description that would
0311     # sit at the top of the email body.
0312     alarm = _require_alarm(entry_id)
0313     description = (alarm.content if alarm else '') or ''
0314 
0315     return render(request, 'monitor_app/alarm_run_report.html', {
0316         'run': run,
0317         'run_started_at': (run.data or {}).get('started_at'),
0318         'run_finished_at': (run.data or {}).get('finished_at'),
0319         'alarm_entry_id': entry_id,
0320         'subject': info.get('bundle_subject', ''),
0321         'description': description,
0322         'new_events': new_events,
0323         'continuing_events': ren_events,
0324         'recipients': info.get('recipients') or [],
0325         'email_enabled': bool(info.get('enabled')),
0326         'bundle_sent': bool(info.get('bundle_sent')),
0327         'errors': info.get('errors'),
0328         'error_message': info.get('error_message') or '',
0329     })
0330 
0331 
0332 def alarm_task_history(request, entry_id: str):
0333     """Per-entity history for one alarm. One row in a strip of colored
0334     cells, one cell per engine tick in the last N hours.
0335 
0336     URL: /prod/alarms/<entry_id>/task/?key=<dedupe_key>&hours=<N>
0337     """
0338     dedupe_key = request.GET.get('key', '')
0339     try:
0340         hours = max(1, int(request.GET.get('hours', 24)))
0341     except (TypeError, ValueError):
0342         hours = 24
0343 
0344     alarm = _require_alarm(entry_id)
0345     if alarm is None:
0346         return HttpResponse(f'Alarm config {entry_id} not found',
0347                             status=404, content_type='text/plain')
0348     if not dedupe_key:
0349         return HttpResponse('key query param required',
0350                             status=400, content_type='text/plain')
0351 
0352     bins = alarms_data.task_history_bins(entry_id, dedupe_key, hours)
0353     events = alarms_data.events_for_task(entry_id, dedupe_key, hours)
0354 
0355     # Collapse the dedupe_key into a human label: "task:35981" → "task 35981".
0356     label = dedupe_key.replace(':', ' ', 1)
0357 
0358     # Group the strip into one-hour blocks, each labeled. A label line
0359     # at the start of a new date is marked so the template can surface
0360     # the date above the time.
0361     bin_groups = []
0362     current = None
0363     last_date = None
0364     for b in bins:
0365         dt = datetime.fromtimestamp(float(b['tick']), tz=_EASTERN)
0366         hour_key = dt.strftime('%Y-%m-%d %H')
0367         date_str = dt.strftime('%Y-%m-%d')
0368         if current is None or current['hour_key'] != hour_key:
0369             current = {
0370                 'hour_key': hour_key,
0371                 'label': dt.strftime('%H:00'),
0372                 'date': date_str,
0373                 'date_change': last_date != date_str,
0374                 'bins': [],
0375             }
0376             last_date = date_str
0377             bin_groups.append(current)
0378         current['bins'].append(b)
0379 
0380     return render(request, 'monitor_app/alarm_task_history.html', {
0381         'alarm_entry_id': entry_id,
0382         'alarm_title': alarm.title or entry_id,
0383         'dedupe_key': dedupe_key,
0384         'label': label,
0385         'hours': hours,
0386         'bins': bins,
0387         'bin_groups': bin_groups,
0388         'events': events,
0389     })
0390 
0391 
0392 def alarm_event_detail(request, event_uuid: str):
0393     event = alarms_data.get_event(event_uuid)
0394     if event is None:
0395         return HttpResponse('Event not found', status=404,
0396                             content_type='text/plain')
0397     return render(request, 'monitor_app/alarm_event_detail.html', {
0398         'event': event,
0399     })
0400 
0401 
0402 # ── alarm config editor ───────────────────────────────────────────────────
0403 
0404 def _require_alarm(entry_id: str) -> Entry | None:
0405     return alarms_data.get_alarm_config_by_entry_id(entry_id)
0406 
0407 
0408 @login_required
0409 def alarm_config_edit(request, entry_id: str):
0410     """GET: render CodeMirror editor for an alarm config."""
0411     alarm = _require_alarm(entry_id)
0412     if alarm is None:
0413         return HttpResponse(f'Alarm config {entry_id} not found',
0414                             status=404, content_type='text/plain')
0415 
0416     versions = alarms_data.versions_for(alarm.id, limit=50)
0417 
0418     data = alarm.data or {}
0419     # First-class form fields — separate from the JSON editor so ops
0420     # can tweak routing/enabled without JSON-syntax risk.
0421     return render(request, 'monitor_app/alarm_config_edit.html', {
0422         'alarm': alarm,
0423         'alarm_entry_id': entry_id,
0424         'title': alarm.title or '',
0425         'content': alarm.content or '',
0426         'line_count': (alarm.content or '').count('\n') + 1,
0427         'enabled': bool(data.get('enabled', True)),
0428         'recipients_text': _recipients_to_text(data.get('recipients')),
0429         'renotification_window_hours': data.get('renotification_window_hours') or 0,
0430         # Only the params the alarm code reads — kind/internal dispatch
0431         # is not user-facing.
0432         'data_json': json.dumps(
0433             dict(data.get('params') or {}), indent=2, sort_keys=True),
0434         'params_meta': _alarm_params_meta(entry_id),
0435         'versions_json': json.dumps(versions, default=str),
0436     })
0437 
0438 
0439 @login_required
0440 @csrf_exempt  # editor posts JSON; token mismatch handled by auth
0441 @require_POST
0442 def alarm_config_save(request, entry_id: str):
0443     """POST: save edits to an alarm config. JSON body:
0444         {
0445           "content": "...",
0446           "data": { enabled, recipients, params },
0447           "autosave": true|false   (optional; marks changed_by=autosave)
0448         }
0449     Returns {version_num, modified}.
0450     """
0451     alarm = _require_alarm(entry_id)
0452     if alarm is None:
0453         return JsonResponse({'error': 'not found'}, status=404)
0454 
0455     try:
0456         payload = json.loads(request.body or b'{}')
0457     except json.JSONDecodeError as e:
0458         return JsonResponse({'error': f'bad json: {e}'}, status=400)
0459 
0460     new_content = payload.get('content')
0461     new_title = payload.get('title')
0462     new_partial = payload.get('data') or {}
0463     if new_content is None:
0464         new_content = alarm.content
0465     if new_title is None:
0466         new_title = alarm.title
0467 
0468     # Merge partial edits onto existing data, preserving entry_id and the
0469     # internal `kind` dispatch key (not user-editable).
0470     existing_data = dict(alarm.data or {})
0471     # The editor's JSON block is the params dict, nothing else.
0472     if new_partial:
0473         existing_data['params'] = new_partial
0474     # First-class fields come at the top level of the payload.
0475     if 'enabled' in payload:
0476         existing_data['enabled'] = bool(payload['enabled'])
0477     if 'recipients' in payload:
0478         # Store verbatim — do NOT re-format user input. The engine splits
0479         # on commas/whitespace at send-time.
0480         existing_data['recipients'] = payload['recipients']
0481     if 'renotification_window_hours' in payload:
0482         try:
0483             existing_data['renotification_window_hours'] = int(float(
0484                 payload['renotification_window_hours']))
0485         except (TypeError, ValueError):
0486             pass
0487     if 'entry_id' not in existing_data:
0488         existing_data['entry_id'] = entry_id  # keep slug stable
0489 
0490     changed_by = 'autosave' if payload.get('autosave') else 'web_ui'
0491     if request.user.is_authenticated:
0492         changed_by = f"{changed_by}:{request.user.username}"
0493     set_changed_by(changed_by)
0494 
0495     alarm.title = new_title
0496     alarm.content = new_content
0497     alarm.data = existing_data
0498     alarm.timestamp_modified = time.time()
0499     alarm.save()
0500 
0501     # Report the new latest version number (signal may or may not have
0502     # snapshotted depending on whether the change was substantive).
0503     latest = (EntryVersion.objects.filter(entry=alarm)
0504               .order_by('-version_num').values('version_num').first())
0505     return JsonResponse({
0506         'version_num': latest['version_num'] if latest else 0,
0507         'modified': alarm.timestamp_modified,
0508     })
0509 
0510 
0511 @login_required
0512 @csrf_exempt
0513 @require_POST
0514 def alarm_test(request, entry_id: str):
0515     """POST: run the alarm's detect() once against live data; never emails.
0516 
0517     JSON body: {"params": {...}}  — overrides the stored params.
0518     Response: {"detections": [...], "count": int, "error": str?, "elapsed_ms": int}
0519 
0520     Uses the engine's own Client (http-only), so Django's venv must have
0521     httpx installed. Engine code is made importable ad-hoc via sys.path.
0522     """
0523     alarm = _require_alarm(entry_id)
0524     if alarm is None:
0525         return JsonResponse({'error': 'not found'}, status=404)
0526     try:
0527         payload = json.loads(request.body or b'{}')
0528     except json.JSONDecodeError as e:
0529         return JsonResponse({'error': f'bad json: {e}'}, status=400)
0530 
0531     params = payload.get('params')
0532     if params is None:
0533         params = dict((alarm.data or {}).get('params') or {})
0534 
0535     try:
0536         mod = _alarm_module(entry_id)
0537     except Exception as e:
0538         return JsonResponse({'error': f'cannot import alarm module: {e}'},
0539                             status=500)
0540 
0541     # Engine client pulls from swf-remote's own loopback proxy.
0542     try:
0543         from swf_alarms.fetch import Client, FetchError  # type: ignore
0544     except Exception as e:
0545         return JsonResponse({'error': f'cannot import engine client: {e}'},
0546                             status=500)
0547 
0548     base_url = getattr(settings, 'SWF_ALARMS_BASE_URL',
0549                        'https://epic-devcloud.org/prod')
0550     client = Client(base_url, timeout=30.0)
0551 
0552     t0 = time.time()
0553     detections: list[dict] = []
0554     error: str | None = None
0555     try:
0556         for det in mod.detect(client, params):
0557             detections.append({
0558                 'dedupe_key': det.dedupe_key,
0559                 'subject': det.subject,
0560                 'body_context': det.body_context,
0561                 'extra_data': det.extra_data,
0562             })
0563             if len(detections) >= 200:  # cap for sanity
0564                 break
0565     except FetchError as e:
0566         error = f'fetch error: {e}'
0567     except Exception:  # noqa: BLE001
0568         error = traceback.format_exc()
0569     elapsed_ms = int((time.time() - t0) * 1000)
0570 
0571     return JsonResponse({
0572         'count': len(detections),
0573         'detections': detections,
0574         'error': error,
0575         'elapsed_ms': elapsed_ms,
0576     })
0577 
0578 
0579 
0580 
0581 @login_required
0582 def alarm_config_version(request, entry_id: str, version_num: int):
0583     """GET: return a specific version's content+data as JSON (for restore)."""
0584     alarm = _require_alarm(entry_id)
0585     if alarm is None:
0586         return JsonResponse({'error': 'not found'}, status=404)
0587     try:
0588         v = EntryVersion.objects.get(entry_id=alarm.id, version_num=version_num)
0589     except EntryVersion.DoesNotExist:
0590         return JsonResponse({'error': 'version not found'}, status=404)
0591     return JsonResponse({
0592         'version_num': v.version_num,
0593         'title': v.title,
0594         'content': v.content,
0595         'data': v.data,
0596         'changed_by': v.changed_by,
0597         'timestamp': v.timestamp,
0598     })
0599 
0600 
0601 # ── teams ─────────────────────────────────────────────────────────────────
0602 
0603 def _team_at_name(raw: str) -> str:
0604     """Normalise to '@<name>'. Strips leading @s and whitespace."""
0605     raw = (raw or '').strip()
0606     raw = raw.lstrip('@')
0607     return '@' + raw if raw else ''
0608 
0609 
0610 @login_required
0611 def team_new(request):
0612     """GET /alarms/teams/new/ — render the team editor in create-mode
0613     with an empty name input. Save in this mode POSTs to team_create,
0614     which returns the new team's edit URL; the client navigates there.
0615 
0616     POST to the same URL is forwarded to team_create so the single URL
0617     `alarms/teams/new/` serves the whole create cycle.
0618     """
0619     if request.method == 'POST':
0620         return team_create(request)
0621     return render(request, 'monitor_app/team_edit.html', {
0622         'team': None,
0623         'team_name': '',
0624         'title': '',
0625         'content': '',
0626         'versions_json': '[]',
0627         'create_mode': True,
0628     })
0629 
0630 
0631 @login_required
0632 @csrf_exempt
0633 @require_POST
0634 def team_create(request):
0635     """Create a new team entry. POST body JSON: {name, title, content}.
0636 
0637     Redirects to the editor page (well — returns JSON with edit URL;
0638     client navigates).
0639     """
0640     try:
0641         payload = json.loads(request.body or b'{}')
0642     except json.JSONDecodeError as e:
0643         return JsonResponse({'error': f'bad json: {e}'}, status=400)
0644 
0645     at_name = _team_at_name(payload.get('name') or '')
0646     if not at_name or at_name == '@':
0647         return JsonResponse({'error': 'name required'}, status=400)
0648 
0649     try:
0650         ctx = EntryContext.objects.get(name='teams')
0651     except EntryContext.DoesNotExist:
0652         return JsonResponse({'error': 'teams context missing — run migrations'},
0653                             status=500)
0654 
0655     if Entry.objects.filter(context=ctx, kind='team', name=at_name).exists():
0656         return JsonResponse({'error': f'team {at_name} already exists'},
0657                             status=409)
0658 
0659     changed_by = 'web_ui'
0660     if request.user.is_authenticated:
0661         changed_by += f':{request.user.username}'
0662     set_changed_by(changed_by)
0663 
0664     e = Entry.objects.create(
0665         id=str(uuid.uuid4()),
0666         title=payload.get('title') or at_name[1:].capitalize(),
0667         content=(payload.get('content') or '').strip(),
0668         kind='team',
0669         context=ctx,
0670         name=at_name,
0671         data={'entry_id': f'team_{at_name[1:]}'},
0672         status='active',
0673         archived=False,
0674         timestamp_created=time.time(),
0675         timestamp_modified=time.time(),
0676     )
0677     return JsonResponse({
0678         'id': e.id,
0679         'name': e.name,
0680         'edit_url': f'/alarms/teams/{at_name}/edit/',
0681     })
0682 
0683 
0684 def _require_team(at_name: str) -> Entry | None:
0685     return alarms_data.get_team(at_name)
0686 
0687 
0688 @login_required
0689 def team_edit(request, at_name: str):
0690     team = _require_team(at_name)
0691     if team is None:
0692         return HttpResponse(f'Team {at_name} not found', status=404,
0693                             content_type='text/plain')
0694     versions = alarms_data.versions_for(team.id, limit=50)
0695     return render(request, 'monitor_app/team_edit.html', {
0696         'team': team,
0697         'team_name': team.name,
0698         'title': team.title or '',
0699         'content': team.content or '',
0700         'versions_json': json.dumps(versions, default=str),
0701     })
0702 
0703 
0704 @login_required
0705 @csrf_exempt
0706 @require_POST
0707 def team_save(request, at_name: str):
0708     team = _require_team(at_name)
0709     if team is None:
0710         return JsonResponse({'error': 'not found'}, status=404)
0711     try:
0712         payload = json.loads(request.body or b'{}')
0713     except json.JSONDecodeError as e:
0714         return JsonResponse({'error': f'bad json: {e}'}, status=400)
0715 
0716     changed_by = 'autosave' if payload.get('autosave') else 'web_ui'
0717     if request.user.is_authenticated:
0718         changed_by = f'{changed_by}:{request.user.username}'
0719     set_changed_by(changed_by)
0720 
0721     if 'title' in payload:
0722         team.title = payload['title'] or ''
0723     if 'content' in payload:
0724         team.content = payload['content'] or ''
0725     team.timestamp_modified = time.time()
0726     team.save()
0727 
0728     latest = (EntryVersion.objects.filter(entry=team)
0729               .order_by('-version_num').values('version_num').first())
0730     return JsonResponse({
0731         'version_num': latest['version_num'] if latest else 0,
0732         'modified': team.timestamp_modified,
0733     })
0734 
0735 
0736 @login_required
0737 def team_version(request, at_name: str, version_num: int):
0738     team = _require_team(at_name)
0739     if team is None:
0740         return JsonResponse({'error': 'not found'}, status=404)
0741     try:
0742         v = EntryVersion.objects.get(entry_id=team.id, version_num=version_num)
0743     except EntryVersion.DoesNotExist:
0744         return JsonResponse({'error': 'version not found'}, status=404)
0745     return JsonResponse({
0746         'version_num': v.version_num,
0747         'title': v.title,
0748         'content': v.content,
0749         'data': v.data,
0750         'changed_by': v.changed_by,
0751         'timestamp': v.timestamp,
0752     })