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
0075
0076
0077
0078
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
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
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
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
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
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
0311
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
0356 label = dedupe_key.replace(':', ' ', 1)
0357
0358
0359
0360
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
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
0420
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
0431
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
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
0469
0470 existing_data = dict(alarm.data or {})
0471
0472 if new_partial:
0473 existing_data['params'] = new_partial
0474
0475 if 'enabled' in payload:
0476 existing_data['enabled'] = bool(payload['enabled'])
0477 if 'recipients' in payload:
0478
0479
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
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
0502
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
0542 try:
0543 from swf_alarms.fetch import Client, FetchError
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:
0564 break
0565 except FetchError as e:
0566 error = f'fetch error: {e}'
0567 except Exception:
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
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 })