File indexing completed on 2026-08-12 09:36:17
0001 """Snapper temporal-query REST adapters (snapper-ai PLAN.md Phase 5).
0002
0003 Thin transports over ``snapper_ai.queries``: each endpoint parses and
0004 validates its parameters, calls the generic query, and returns the typed
0005 evidence envelope's serialization unchanged — actual snap times,
0006 schema/policy versions, provenance, hashes, and observer coverage are
0007 the contract, and no adapter may present inferred continuity as fact.
0008
0009 Read-open like the rest of the monitor's read surfaces; errors are
0010 explicit JSON, never empty results.
0011 """
0012
0013 from django.http import JsonResponse
0014 from django.utils.dateparse import parse_datetime
0015
0016 from snapper_ai.queries import (InvalidQuery, SnapNotFound, SnapperError,
0017 changes_between, component_history,
0018 context_around, latest, state_at)
0019
0020 from ..snapper_resolvers import annotate_references
0021
0022
0023 def _parse_time(raw, label):
0024 value = parse_datetime(str(raw or '').strip())
0025 if value is None:
0026 raise InvalidQuery(
0027 f'{label} must be an ISO 8601 datetime, e.g. 2026-07-23T04:00:00Z')
0028 if value.tzinfo is None:
0029 raise InvalidQuery(f'{label} must carry an explicit timezone offset')
0030 return value
0031
0032
0033 def _run(query):
0034 try:
0035 result = query()
0036 except InvalidQuery as e:
0037 return JsonResponse({'error': str(e)}, status=400)
0038 except SnapNotFound as e:
0039 return JsonResponse({'error': str(e)}, status=404)
0040 except SnapperError as e:
0041 return JsonResponse({'error': str(e)}, status=500)
0042 payload = result.as_dict()
0043 return JsonResponse(payload, json_dumps_params={'default': str})
0044
0045
0046 def snapper_latest(request, scope):
0047 """GET /api/snapper/<scope>/latest/"""
0048 return _run(lambda: latest(scope))
0049
0050
0051 def snapper_state_at(request, scope):
0052 """GET /api/snapper/<scope>/state-at/?time=<ISO 8601>"""
0053 return _run(lambda: state_at(scope, _parse_time(
0054 request.GET.get('time'), 'time')))
0055
0056
0057 def snapper_component_history(request, scope):
0058 """GET /api/snapper/<scope>/history/?component=&start=&end=
0059 [&include_unchanged=1]"""
0060 def query():
0061 component = str(request.GET.get('component') or '').strip()
0062 if not component:
0063 raise InvalidQuery('component is required')
0064 return component_history(
0065 scope, component,
0066 _parse_time(request.GET.get('start'), 'start'),
0067 _parse_time(request.GET.get('end'), 'end'),
0068 suppress_unchanged_baselines=(
0069 request.GET.get('include_unchanged') != '1'),
0070 )
0071 return _run(query)
0072
0073
0074 def snapper_changes_between(request, scope):
0075 """GET /api/snapper/<scope>/changes/?start=&end="""
0076 return _run(lambda: changes_between(
0077 scope,
0078 _parse_time(request.GET.get('start'), 'start'),
0079 _parse_time(request.GET.get('end'), 'end')))
0080
0081
0082 def system_status_history(request):
0083 """GET /api/system-status/history/?name=&start=&end=&limit=
0084
0085 Read surface for the append-only health observations — the
0086 authoritative event stream behind the assessed health component
0087 (resolver swf-system-status-history).
0088 """
0089 from ..models import SystemStatusHistory
0090
0091 rows = SystemStatusHistory.objects.order_by('-checked_at')
0092 name = (request.GET.get('name') or '').strip()
0093 if name:
0094 rows = rows.filter(name=name)
0095 raw_start = request.GET.get('start')
0096 raw_end = request.GET.get('end')
0097 try:
0098 if raw_start:
0099 rows = rows.filter(checked_at__gte=_parse_time(raw_start, 'start'))
0100 if raw_end:
0101 rows = rows.filter(checked_at__lt=_parse_time(raw_end, 'end'))
0102 except InvalidQuery as e:
0103 return JsonResponse({'error': str(e)}, status=400)
0104 try:
0105 limit = min(int(request.GET.get('limit') or 500), 2000)
0106 except ValueError:
0107 return JsonResponse({'error': 'limit must be an integer'}, status=400)
0108 if limit < 0:
0109 return JsonResponse({'error': 'limit must be non-negative'},
0110 status=400)
0111 observations = list(rows.values(
0112 'name', 'category', 'status', 'summary', 'checked_at')[:limit])
0113 return JsonResponse({'count': len(observations),
0114 'observations': observations},
0115 json_dumps_params={'default': str})
0116
0117
0118 def snapper_context(request, scope):
0119 """GET /api/snapper/<scope>/context/?time=<ISO>[&window=seconds]
0120
0121 State at the instant, changes in the window around it, and event
0122 references with their SWF resolver transports attached.
0123 """
0124 import math
0125
0126 try:
0127 window = float(request.GET.get('window') or 3600)
0128 except ValueError:
0129 return JsonResponse({'error': 'window must be a number of seconds'},
0130 status=400)
0131 if not math.isfinite(window) or window <= 0:
0132 return JsonResponse({'error': 'window must be a positive finite '
0133 'number of seconds'}, status=400)
0134 try:
0135 result = context_around(
0136 scope, _parse_time(request.GET.get('time'), 'time'), window)
0137 except InvalidQuery as e:
0138 return JsonResponse({'error': str(e)}, status=400)
0139 except SnapNotFound as e:
0140 return JsonResponse({'error': str(e)}, status=404)
0141 except (SnapperError, ValueError) as e:
0142 return JsonResponse({'error': str(e)}, status=500)
0143 payload = result.as_dict()
0144 payload['references'] = annotate_references(payload['references'])
0145 return JsonResponse(payload, json_dumps_params={'default': str})