"""Drone Ops routes: professional workflow for detection, incidents, actions, and evidence.""" from __future__ import annotations from flask import Blueprint, Response, jsonify, request from utils.authz import current_username, require_armed, require_role from utils.database import ( get_action_request, get_drone_incident, get_evidence_manifest, list_action_audit_logs, list_action_requests, list_drone_correlations, list_drone_sessions, list_drone_incidents, list_evidence_manifests, ) from utils.drone import get_drone_ops_service from utils.sse import format_sse drone_ops_bp = Blueprint('drone_ops', __name__, url_prefix='/drone-ops') def _json_body() -> dict: return request.get_json(silent=True) or {} @drone_ops_bp.route('/status', methods=['GET']) @require_role('viewer') def status() -> Response: service = get_drone_ops_service() return jsonify(service.get_status()) @drone_ops_bp.route('/sessions', methods=['GET']) @require_role('viewer') def list_sessions() -> Response: limit = max(1, min(500, request.args.get('limit', default=50, type=int))) active_only = request.args.get('active_only', 'false').lower() == 'true' return jsonify({ 'status': 'success', 'sessions': list_drone_sessions(limit=limit, active_only=active_only), }) @drone_ops_bp.route('/session/start', methods=['POST']) @require_role('operator') def start_session() -> Response: data = _json_body() mode = str(data.get('mode') or 'passive').strip().lower() if mode not in {'passive', 'active'}: return jsonify({'status': 'error', 'message': 'mode must be passive or active'}), 400 label = data.get('label') metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} service = get_drone_ops_service() session = service.start_session( mode=mode, label=label, operator=current_username(), metadata=metadata, ) return jsonify({'status': 'success', 'session': session}) @drone_ops_bp.route('/session/stop', methods=['POST']) @require_role('operator') def stop_session() -> Response: data = _json_body() session_id = data.get('id') try: session_id_int = int(session_id) if session_id is not None else None except (TypeError, ValueError): return jsonify({'status': 'error', 'message': 'id must be an integer'}), 400 summary = data.get('summary') if isinstance(data.get('summary'), dict) else None service = get_drone_ops_service() session = service.stop_session( operator=current_username(), session_id=session_id_int, summary=summary, ) if not session: return jsonify({'status': 'error', 'message': 'No active session found'}), 404 return jsonify({'status': 'success', 'session': session}) @drone_ops_bp.route('/detections', methods=['GET']) @require_role('viewer') def detections() -> Response: service = get_drone_ops_service() source = request.args.get('source') min_confidence = request.args.get('min_confidence', default=0.0, type=float) limit = max(1, min(5000, request.args.get('limit', default=200, type=int))) session_id = request.args.get('session_id', default=None, type=int) rows = service.get_detections( session_id=session_id, source=source, min_confidence=min_confidence, limit=limit, ) return jsonify({'status': 'success', 'count': len(rows), 'detections': rows}) @drone_ops_bp.route('/stream', methods=['GET']) @require_role('viewer') def stream() -> Response: service = get_drone_ops_service() def _generate(): for event in service.stream_events(timeout=1.0): evt_name = event.get('type') if isinstance(event, dict) else None payload = event yield format_sse(payload, event=evt_name) response = Response(_generate(), mimetype='text/event-stream') response.headers['Cache-Control'] = 'no-cache' response.headers['Connection'] = 'keep-alive' response.headers['X-Accel-Buffering'] = 'no' return response @drone_ops_bp.route('/remote-id/decode', methods=['POST']) @require_role('analyst') def decode_remote_id() -> Response: data = _json_body() payload = data.get('payload') if payload is None: return jsonify({'status': 'error', 'message': 'payload is required'}), 400 service = get_drone_ops_service() decoded = service.decode_remote_id(payload) return jsonify({'status': 'success', 'decoded': decoded}) @drone_ops_bp.route('/tracks', methods=['GET']) @require_role('viewer') def tracks() -> Response: service = get_drone_ops_service() detection_id = request.args.get('detection_id', default=None, type=int) identifier = request.args.get('identifier') limit = max(1, min(5000, request.args.get('limit', default=1000, type=int))) rows = service.get_tracks(detection_id=detection_id, identifier=identifier, limit=limit) return jsonify({'status': 'success', 'count': len(rows), 'tracks': rows}) @drone_ops_bp.route('/geolocate/estimate', methods=['POST']) @require_role('analyst') def geolocate_estimate() -> Response: data = _json_body() observations = data.get('observations') environment = str(data.get('environment') or 'outdoor') if not isinstance(observations, list) or len(observations) < 3: return jsonify({'status': 'error', 'message': 'at least 3 observations are required'}), 400 service = get_drone_ops_service() try: location = service.estimate_geolocation(observations=observations, environment=environment) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'success', 'location': location}) @drone_ops_bp.route('/correlations', methods=['GET']) @require_role('analyst') def correlations() -> Response: min_confidence = request.args.get('min_confidence', default=0.6, type=float) refresh = request.args.get('refresh', 'true').lower() == 'true' service = get_drone_ops_service() if refresh: rows = service.refresh_correlations(min_confidence=min_confidence) else: rows = list_drone_correlations(min_confidence=min_confidence, limit=200) return jsonify({'status': 'success', 'count': len(rows), 'correlations': rows}) @drone_ops_bp.route('/incidents', methods=['GET']) @require_role('viewer') def incidents_list() -> Response: status = request.args.get('status') limit = max(1, min(1000, request.args.get('limit', default=100, type=int))) rows = list_drone_incidents(status=status, limit=limit) return jsonify({'status': 'success', 'count': len(rows), 'incidents': rows}) @drone_ops_bp.route('/incidents', methods=['POST']) @require_role('operator') def incidents_create() -> Response: data = _json_body() title = str(data.get('title') or '').strip() if not title: return jsonify({'status': 'error', 'message': 'title is required'}), 400 severity = str(data.get('severity') or 'medium').strip().lower() summary = data.get('summary') metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} service = get_drone_ops_service() incident = service.create_incident( title=title, severity=severity, opened_by=current_username(), summary=summary, metadata=metadata, ) return jsonify({'status': 'success', 'incident': incident}), 201 @drone_ops_bp.route('/incidents/', methods=['GET']) @require_role('viewer') def incidents_get(incident_id: int) -> Response: incident = get_drone_incident(incident_id) if not incident: return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 return jsonify({'status': 'success', 'incident': incident}) @drone_ops_bp.route('/incidents/', methods=['PUT']) @require_role('operator') def incidents_update(incident_id: int) -> Response: data = _json_body() service = get_drone_ops_service() incident = service.update_incident( incident_id=incident_id, status=data.get('status'), severity=data.get('severity'), summary=data.get('summary'), metadata=data.get('metadata') if isinstance(data.get('metadata'), dict) else None, ) if not incident: return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 return jsonify({'status': 'success', 'incident': incident}) @drone_ops_bp.route('/incidents//artifacts', methods=['POST']) @require_role('operator') def incidents_add_artifact(incident_id: int) -> Response: if not get_drone_incident(incident_id): return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 data = _json_body() artifact_type = str(data.get('artifact_type') or '').strip() artifact_ref = str(data.get('artifact_ref') or '').strip() metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} if not artifact_type or not artifact_ref: return jsonify({'status': 'error', 'message': 'artifact_type and artifact_ref are required'}), 400 service = get_drone_ops_service() artifact = service.add_incident_artifact( incident_id=incident_id, artifact_type=artifact_type, artifact_ref=artifact_ref, added_by=current_username(), metadata=metadata, ) return jsonify({'status': 'success', 'artifact': artifact}), 201 @drone_ops_bp.route('/actions/arm', methods=['POST']) @require_role('operator') def actions_arm() -> Response: data = _json_body() reason = str(data.get('reason') or '').strip() incident_id = data.get('incident_id') duration_seconds = data.get('duration_seconds', 900) if not reason: return jsonify({'status': 'error', 'message': 'reason is required'}), 400 try: incident_id_int = int(incident_id) except (TypeError, ValueError): return jsonify({'status': 'error', 'message': 'incident_id is required and must be an integer'}), 400 if not get_drone_incident(incident_id_int): return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 service = get_drone_ops_service() state = service.arm_actions( actor=current_username(), reason=reason, incident_id=incident_id_int, duration_seconds=duration_seconds, ) return jsonify({'status': 'success', 'policy': state}) @drone_ops_bp.route('/actions/disarm', methods=['POST']) @require_role('operator') def actions_disarm() -> Response: data = _json_body() reason = str(data.get('reason') or '').strip() or None service = get_drone_ops_service() state = service.disarm_actions(actor=current_username(), reason=reason) return jsonify({'status': 'success', 'policy': state}) @drone_ops_bp.route('/actions/request', methods=['POST']) @require_role('operator') def actions_request() -> Response: data = _json_body() try: incident_id = int(data.get('incident_id')) except (TypeError, ValueError): return jsonify({'status': 'error', 'message': 'incident_id is required'}), 400 if not get_drone_incident(incident_id): return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 action_type = str(data.get('action_type') or '').strip() if not action_type: return jsonify({'status': 'error', 'message': 'action_type is required'}), 400 payload = data.get('payload') if isinstance(data.get('payload'), dict) else {} service = get_drone_ops_service() action_request = service.request_action( incident_id=incident_id, action_type=action_type, requested_by=current_username(), payload=payload, ) return jsonify({'status': 'success', 'request': action_request}), 201 @drone_ops_bp.route('/actions/approve/', methods=['POST']) @require_role('supervisor') def actions_approve(request_id: int) -> Response: data = _json_body() decision = str(data.get('decision') or 'approved').strip().lower() notes = data.get('notes') if decision not in {'approved', 'rejected'}: return jsonify({'status': 'error', 'message': 'decision must be approved or rejected'}), 400 service = get_drone_ops_service() req = service.approve_action( request_id=request_id, approver=current_username(), decision=decision, notes=notes, ) if not req: return jsonify({'status': 'error', 'message': 'Action request not found'}), 404 return jsonify({'status': 'success', 'request': req}) @drone_ops_bp.route('/actions/execute/', methods=['POST']) @require_role('operator') @require_armed def actions_execute(request_id: int) -> Response: service = get_drone_ops_service() req, error = service.execute_action(request_id=request_id, actor=current_username()) if error: return jsonify({'status': 'error', 'message': error}), 400 return jsonify({'status': 'success', 'request': req}) @drone_ops_bp.route('/actions/requests', methods=['GET']) @require_role('viewer') def actions_list() -> Response: incident_id = request.args.get('incident_id', default=None, type=int) status = request.args.get('status') limit = max(1, min(1000, request.args.get('limit', default=100, type=int))) rows = list_action_requests(incident_id=incident_id, status=status, limit=limit) return jsonify({'status': 'success', 'count': len(rows), 'requests': rows}) @drone_ops_bp.route('/actions/requests/', methods=['GET']) @require_role('viewer') def actions_get(request_id: int) -> Response: row = get_action_request(request_id) if not row: return jsonify({'status': 'error', 'message': 'Action request not found'}), 404 return jsonify({'status': 'success', 'request': row}) @drone_ops_bp.route('/actions/audit', methods=['GET']) @require_role('viewer') def actions_audit() -> Response: request_id = request.args.get('request_id', default=None, type=int) limit = max(1, min(2000, request.args.get('limit', default=200, type=int))) rows = list_action_audit_logs(request_id=request_id, limit=limit) return jsonify({'status': 'success', 'count': len(rows), 'events': rows}) @drone_ops_bp.route('/evidence//manifest', methods=['POST']) @require_role('analyst') def evidence_manifest_create(incident_id: int) -> Response: if not get_drone_incident(incident_id): return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 data = _json_body() signature = data.get('signature') service = get_drone_ops_service() manifest = service.generate_evidence_manifest( incident_id=incident_id, created_by=current_username(), signature=signature, ) if not manifest: return jsonify({'status': 'error', 'message': 'Failed to generate manifest'}), 500 return jsonify({'status': 'success', 'manifest': manifest}), 201 @drone_ops_bp.route('/evidence/manifests/', methods=['GET']) @require_role('viewer') def evidence_manifest_get(manifest_id: int) -> Response: row = get_evidence_manifest(manifest_id) if not row: return jsonify({'status': 'error', 'message': 'Manifest not found'}), 404 return jsonify({'status': 'success', 'manifest': row}) @drone_ops_bp.route('/evidence//manifests', methods=['GET']) @require_role('viewer') def evidence_manifest_list(incident_id: int) -> Response: limit = max(1, min(500, request.args.get('limit', default=50, type=int))) rows = list_evidence_manifests(incident_id=incident_id, limit=limit) return jsonify({'status': 'success', 'count': len(rows), 'manifests': rows})