Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-06-26 08:40:24

0001 """Alarm dashboard + editor views.
0002 
0003 The dashboard lives at /swf-monitor/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: /swf-monitor/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 /swf-monitor/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/monitor_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: /swf-monitor/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-monitor's own API.
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(
0549         settings,
0550         'SWF_ALARMS_BASE_URL',
0551         getattr(settings, 'SWF_MONITOR_URL',
0552                 'https://pandaserver02.sdcc.bnl.gov/swf-monitor'),
0553     )
0554     client = Client(base_url, timeout=30.0)
0555 
0556     t0 = time.time()
0557     detections: list[dict] = []
0558     error: str | None = None
0559     try:
0560         for det in mod.detect(client, params):
0561             detections.append({
0562                 'dedupe_key': det.dedupe_key,
0563                 'subject': det.subject,
0564                 'body_context': det.body_context,
0565                 'extra_data': det.extra_data,
0566             })
0567             if len(detections) >= 200:  # cap for sanity
0568                 break
0569     except FetchError as e:
0570         error = f'fetch error: {e}'
0571     except Exception:  # noqa: BLE001
0572         error = traceback.format_exc()
0573     elapsed_ms = int((time.time() - t0) * 1000)
0574 
0575     return JsonResponse({
0576         'count': len(detections),
0577         'detections': detections,
0578         'error': error,
0579         'elapsed_ms': elapsed_ms,
0580     })
0581 
0582 
0583 
0584 
0585 @login_required
0586 def alarm_config_version(request, entry_id: str, version_num: int):
0587     """GET: return a specific version's content+data as JSON (for restore)."""
0588     alarm = _require_alarm(entry_id)
0589     if alarm is None:
0590         return JsonResponse({'error': 'not found'}, status=404)
0591     try:
0592         v = EntryVersion.objects.get(entry_id=alarm.id, version_num=version_num)
0593     except EntryVersion.DoesNotExist:
0594         return JsonResponse({'error': 'version not found'}, status=404)
0595     return JsonResponse({
0596         'version_num': v.version_num,
0597         'title': v.title,
0598         'content': v.content,
0599         'data': v.data,
0600         'changed_by': v.changed_by,
0601         'timestamp': v.timestamp,
0602     })
0603 
0604 
0605 # ── teams ─────────────────────────────────────────────────────────────────
0606 
0607 def _team_at_name(raw: str) -> str:
0608     """Normalise to '@<name>'. Strips leading @s and whitespace."""
0609     raw = (raw or '').strip()
0610     raw = raw.lstrip('@')
0611     return '@' + raw if raw else ''
0612 
0613 
0614 @login_required
0615 def team_new(request):
0616     """GET /alarms/teams/new/ — render the team editor in create-mode
0617     with an empty name input. Save in this mode POSTs to team_create,
0618     which returns the new team's edit URL; the client navigates there.
0619 
0620     POST to the same URL is forwarded to team_create so the single URL
0621     `alarms/teams/new/` serves the whole create cycle.
0622     """
0623     if request.method == 'POST':
0624         return team_create(request)
0625     return render(request, 'monitor_app/team_edit.html', {
0626         'team': None,
0627         'team_name': '',
0628         'title': '',
0629         'content': '',
0630         'versions_json': '[]',
0631         'create_mode': True,
0632     })
0633 
0634 
0635 @login_required
0636 @csrf_exempt
0637 @require_POST
0638 def team_create(request):
0639     """Create a new team entry. POST body JSON: {name, title, content}.
0640 
0641     Redirects to the editor page (well — returns JSON with edit URL;
0642     client navigates).
0643     """
0644     try:
0645         payload = json.loads(request.body or b'{}')
0646     except json.JSONDecodeError as e:
0647         return JsonResponse({'error': f'bad json: {e}'}, status=400)
0648 
0649     at_name = _team_at_name(payload.get('name') or '')
0650     if not at_name or at_name == '@':
0651         return JsonResponse({'error': 'name required'}, status=400)
0652 
0653     try:
0654         ctx = EntryContext.objects.get(name='teams')
0655     except EntryContext.DoesNotExist:
0656         return JsonResponse({'error': 'teams context missing — run migrations'},
0657                             status=500)
0658 
0659     if Entry.objects.filter(context=ctx, kind='team', name=at_name).exists():
0660         return JsonResponse({'error': f'team {at_name} already exists'},
0661                             status=409)
0662 
0663     changed_by = 'web_ui'
0664     if request.user.is_authenticated:
0665         changed_by += f':{request.user.username}'
0666     set_changed_by(changed_by)
0667 
0668     e = Entry.objects.create(
0669         id=str(uuid.uuid4()),
0670         title=payload.get('title') or at_name[1:].capitalize(),
0671         content=(payload.get('content') or '').strip(),
0672         kind='team',
0673         context=ctx,
0674         name=at_name,
0675         data={'entry_id': f'team_{at_name[1:]}'},
0676         status='active',
0677         archived=False,
0678         timestamp_created=time.time(),
0679         timestamp_modified=time.time(),
0680     )
0681     return JsonResponse({
0682         'id': e.id,
0683         'name': e.name,
0684         'edit_url': f'/alarms/teams/{at_name}/edit/',
0685     })
0686 
0687 
0688 def _require_team(at_name: str) -> Entry | None:
0689     return alarms_data.get_team(at_name)
0690 
0691 
0692 @login_required
0693 def team_edit(request, at_name: str):
0694     team = _require_team(at_name)
0695     if team is None:
0696         return HttpResponse(f'Team {at_name} not found', status=404,
0697                             content_type='text/plain')
0698     versions = alarms_data.versions_for(team.id, limit=50)
0699     return render(request, 'monitor_app/team_edit.html', {
0700         'team': team,
0701         'team_name': team.name,
0702         'title': team.title or '',
0703         'content': team.content or '',
0704         'versions_json': json.dumps(versions, default=str),
0705     })
0706 
0707 
0708 @login_required
0709 @csrf_exempt
0710 @require_POST
0711 def team_save(request, at_name: str):
0712     team = _require_team(at_name)
0713     if team is None:
0714         return JsonResponse({'error': 'not found'}, status=404)
0715     try:
0716         payload = json.loads(request.body or b'{}')
0717     except json.JSONDecodeError as e:
0718         return JsonResponse({'error': f'bad json: {e}'}, status=400)
0719 
0720     changed_by = 'autosave' if payload.get('autosave') else 'web_ui'
0721     if request.user.is_authenticated:
0722         changed_by = f'{changed_by}:{request.user.username}'
0723     set_changed_by(changed_by)
0724 
0725     if 'title' in payload:
0726         team.title = payload['title'] or ''
0727     if 'content' in payload:
0728         team.content = payload['content'] or ''
0729     team.timestamp_modified = time.time()
0730     team.save()
0731 
0732     latest = (EntryVersion.objects.filter(entry=team)
0733               .order_by('-version_num').values('version_num').first())
0734     return JsonResponse({
0735         'version_num': latest['version_num'] if latest else 0,
0736         'modified': team.timestamp_modified,
0737     })
0738 
0739 
0740 @login_required
0741 def team_version(request, at_name: str, version_num: int):
0742     team = _require_team(at_name)
0743     if team is None:
0744         return JsonResponse({'error': 'not found'}, status=404)
0745     try:
0746         v = EntryVersion.objects.get(entry_id=team.id, version_num=version_num)
0747     except EntryVersion.DoesNotExist:
0748         return JsonResponse({'error': 'version not found'}, status=404)
0749     return JsonResponse({
0750         'version_num': v.version_num,
0751         'title': v.title,
0752         'content': v.content,
0753         'data': v.data,
0754         'changed_by': v.changed_by,
0755         'timestamp': v.timestamp,
0756     })