Files
intercept/routes/tscm/schedules.py
Smittix e00fbfddc1 v2.26.0: fix SSE fanout crash and branded logo FOUC
- Fix SSE fanout thread AttributeError when source queue is None during
  interpreter shutdown by snapshotting to local variable with null guard
- Fix branded "i" logo rendering oversized on first page load (FOUC) by
  adding inline width/height to SVG elements across 10 templates
- Bump version to 2.26.0 in config.py, pyproject.toml, and CHANGELOG.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:51:27 +00:00

186 lines
6.3 KiB
Python

"""
TSCM Schedule Routes
Handles /schedules/* endpoints for automated sweep scheduling.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
from flask import jsonify, request
from routes.tscm import (
_get_schedule_timezone,
_next_run_from_cron,
_start_sweep_internal,
tscm_bp,
)
from utils.database import (
create_tscm_schedule,
delete_tscm_schedule,
get_all_tscm_schedules,
get_tscm_schedule,
update_tscm_schedule,
)
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/schedules', methods=['GET'])
def list_schedules():
"""List all TSCM sweep schedules."""
enabled_param = request.args.get('enabled')
enabled = None
if enabled_param is not None:
enabled = enabled_param.lower() in ('1', 'true', 'yes')
schedules = get_all_tscm_schedules(enabled=enabled, limit=200)
return jsonify({
'status': 'success',
'count': len(schedules),
'schedules': schedules,
})
@tscm_bp.route('/schedules', methods=['POST'])
def create_schedule():
"""Create a new sweep schedule."""
data = request.get_json() or {}
name = (data.get('name') or '').strip()
cron_expression = (data.get('cron_expression') or '').strip()
sweep_type = data.get('sweep_type', 'standard')
baseline_id = data.get('baseline_id')
zone_name = data.get('zone_name')
enabled = bool(data.get('enabled', True))
notify_on_threat = bool(data.get('notify_on_threat', True))
notify_email = data.get('notify_email')
if not name:
return jsonify({'status': 'error', 'message': 'Schedule name required'}), 400
if not cron_expression:
return jsonify({'status': 'error', 'message': 'cron_expression required'}), 400
next_run = None
if enabled:
try:
tz = _get_schedule_timezone(zone_name)
next_local = _next_run_from_cron(cron_expression, datetime.now(tz))
next_run = next_local.astimezone(timezone.utc).isoformat() if next_local else None
except Exception as e:
return jsonify({'status': 'error', 'message': f'Invalid cron: {e}'}), 400
schedule_id = create_tscm_schedule(
name=name,
cron_expression=cron_expression,
sweep_type=sweep_type,
baseline_id=baseline_id,
zone_name=zone_name,
enabled=enabled,
notify_on_threat=notify_on_threat,
notify_email=notify_email,
next_run=next_run,
)
schedule = get_tscm_schedule(schedule_id)
return jsonify({
'status': 'success',
'message': 'Schedule created',
'schedule': schedule
})
@tscm_bp.route('/schedules/<int:schedule_id>', methods=['PUT', 'PATCH'])
def update_schedule(schedule_id: int):
"""Update a sweep schedule."""
schedule = get_tscm_schedule(schedule_id)
if not schedule:
return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404
data = request.get_json() or {}
updates: dict[str, Any] = {}
for key in ('name', 'cron_expression', 'sweep_type', 'baseline_id', 'zone_name', 'notify_email'):
if key in data:
updates[key] = data[key]
if 'baseline_id' in updates and updates['baseline_id'] in ('', None):
updates['baseline_id'] = None
if 'enabled' in data:
updates['enabled'] = 1 if data['enabled'] else 0
if 'notify_on_threat' in data:
updates['notify_on_threat'] = 1 if data['notify_on_threat'] else 0
# Recalculate next_run when cron/zone/enabled changes
if any(k in updates for k in ('cron_expression', 'zone_name', 'enabled')):
if updates.get('enabled', schedule.get('enabled', 1)):
cron_expr = updates.get('cron_expression', schedule.get('cron_expression', ''))
zone_name = updates.get('zone_name', schedule.get('zone_name'))
try:
tz = _get_schedule_timezone(zone_name)
next_local = _next_run_from_cron(cron_expr, datetime.now(tz))
updates['next_run'] = next_local.astimezone(timezone.utc).isoformat() if next_local else None
except Exception as e:
return jsonify({'status': 'error', 'message': f'Invalid cron: {e}'}), 400
else:
updates['next_run'] = None
if not updates:
return jsonify({'status': 'error', 'message': 'No updates provided'}), 400
update_tscm_schedule(schedule_id, **updates)
schedule = get_tscm_schedule(schedule_id)
return jsonify({'status': 'success', 'schedule': schedule})
@tscm_bp.route('/schedules/<int:schedule_id>', methods=['DELETE'])
def delete_schedule(schedule_id: int):
"""Delete a sweep schedule."""
success = delete_tscm_schedule(schedule_id)
if not success:
return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404
return jsonify({'status': 'success', 'message': 'Schedule deleted'})
@tscm_bp.route('/schedules/<int:schedule_id>/run', methods=['POST'])
def run_schedule_now(schedule_id: int):
"""Trigger a scheduled sweep immediately."""
schedule = get_tscm_schedule(schedule_id)
if not schedule:
return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404
result = _start_sweep_internal(
sweep_type=schedule.get('sweep_type') or 'standard',
baseline_id=schedule.get('baseline_id'),
wifi_enabled=True,
bt_enabled=True,
rf_enabled=True,
wifi_interface='',
bt_interface='',
sdr_device=None,
verbose_results=False,
)
if result.get('status') != 'success':
status_code = result.pop('http_status', 400)
return jsonify(result), status_code
# Update schedule run timestamps
cron_expr = schedule.get('cron_expression') or ''
tz = _get_schedule_timezone(schedule.get('zone_name'))
now_utc = datetime.now(timezone.utc)
try:
next_local = _next_run_from_cron(cron_expr, datetime.now(tz))
except Exception:
next_local = None
update_tscm_schedule(
schedule_id,
last_run=now_utc.isoformat(),
next_run=next_local.astimezone(timezone.utc).isoformat() if next_local else None,
)
return jsonify(result)