""" WMT (Workstation Management Terminal) configuration API Handles config distribution and device update requests from WMT clients. """ from flask import Blueprint, request, jsonify, send_file from datetime import datetime from pathlib import Path import json from app.models import WMTGlobalConfig, Device, WMTUpdateRequest from config.database_config import get_db import logging logger = logging.getLogger(__name__) wmt_api_bp = Blueprint('wmt_api', __name__, url_prefix='/api/wmt') # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _get_or_create_global_config(session): """Return the single WMTGlobalConfig row, creating it with defaults if absent.""" cfg = session.query(WMTGlobalConfig).first() if cfg is None: cfg = WMTGlobalConfig() session.add(cfg) session.flush() return cfg def _latest_config_ts(session, mac_address): """Return timestamps for global config and this device's admin-reviewed info.""" global_cfg = session.query(WMTGlobalConfig).first() global_ts = global_cfg.updated_at if global_cfg and global_cfg.updated_at else datetime(1970, 1, 1) device = session.query(Device).filter_by(mac_address=mac_address).first() # Use info_reviewed_at as the authoritative device-level timestamp device_ts = device.info_reviewed_at if device and device.info_reviewed_at else datetime(1970, 1, 1) latest = max(global_ts, device_ts) return global_ts, device_ts, latest # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @wmt_api_bp.route('/config/timestamp', methods=['GET']) def get_config_timestamp(): """ Returns the last-modified timestamps for global config and this device's config. Query param: mac= Response: { "global_updated_at": "2026-04-22T10:00:00", "device_updated_at": "2026-04-22T09:00:00", // null if device unknown "latest_updated_at": "2026-04-22T10:00:00" } """ mac = request.args.get('mac', '').strip().lower() if not mac: return jsonify({'error': 'mac query parameter is required'}), 400 try: with get_db().get_session() as session: global_ts, device_info_reviewed_ts, latest = _latest_config_ts(session, mac) return jsonify({ 'global_updated_at': global_ts.isoformat() if global_ts != datetime(1970, 1, 1) else None, 'device_info_reviewed_at': device_info_reviewed_ts.isoformat() if device_info_reviewed_ts != datetime(1970, 1, 1) else None, 'latest_updated_at': latest.isoformat(), }), 200 except Exception as e: logger.error(f'Error getting WMT config timestamp: {e}') return jsonify({'error': str(e)}), 500 @wmt_api_bp.route('/config/', methods=['GET']) def get_device_config(mac_address): """ Returns merged config (global settings + device-specific) for a given MAC. Used by WMT client to pull updated config at startup. Response: merged dict consumable by the WMT config.txt writer. """ mac = mac_address.strip().lower() try: with get_db().get_session() as session: global_cfg = _get_or_create_global_config(session) device = session.query(Device).filter_by(mac_address=mac).first() # Update last_seen if device is known if device: device.last_seen = datetime.utcnow() _, device_ts, latest_ts = _latest_config_ts(session, mac) payload = { # Global settings (device custom_chrome_url overrides global chrome_url if set) 'chrome_url': (device.custom_chrome_url if device and device.custom_chrome_url else global_cfg.chrome_url), 'chrome_local_url': global_cfg.chrome_local_url or '', 'chrome_insecure_origin': global_cfg.chrome_insecure_origin, 'card_api_base_url': global_cfg.card_api_base_url, 'server_log_url': global_cfg.server_log_url, 'internet_check_host': global_cfg.internet_check_host, 'update_host': global_cfg.update_host, 'update_user': global_cfg.update_user, # Device-specific settings (empty string if unknown) 'device_name': device.device_name if device else '', 'hostname': device.hostname if device else '', 'device_ip': device.device_ip if device else '', 'location': device.location if device else '', 'card_presence': device.card_presence if device else 'enable', # Admin-review timestamp for device info (client stores in [device] section) 'info_reviewed_at': device.info_reviewed_at.isoformat() if (device and device.info_reviewed_at) else '1970-01-01T00:00:00', # Sync metadata 'config_updated_at': latest_ts.isoformat(), } return jsonify(payload), 200 except Exception as e: logger.error(f'Error fetching WMT config for {mac}: {e}') return jsonify({'error': str(e)}), 500 @wmt_api_bp.route('/config/update_request', methods=['POST']) def submit_update_request(): """ WMT client sends its current running config so the server can validate it. Three outcomes: 1. Device known AND config matches server record → record config_synced_at = now, update last_seen → respond {"status": "ok", "in_sync": true} Client does nothing. 2. Device known BUT config differs from server record → the server is authoritative; client must pull fresh config → respond {"status": "sync_required", "in_sync": false} Client should call GET /api/wmt/config/ to obtain correct values. 3. Device unknown (new client – not registered by MAC or hostname) → create WMTUpdateRequest so admin can approve and assign settings → respond {"status": "pending_approval"} Client waits; it will get real config once admin approves. card_presence is always applied directly – no approval required. Expected JSON: { "mac_address": "b8:27:eb:aa:bb:cc", "device_name": "Masa-01", "hostname": "rpi-masa01", "device_ip": "192.168.1.100", "card_presence": "enable", "client_config_mtime": "2026-04-22T09:30:00" } """ if not request.is_json: return jsonify({'error': 'Content-Type must be application/json'}), 400 data = request.get_json() mac = (data.get('mac_address') or '').strip().lower() if not mac: return jsonify({'error': 'mac_address is required'}), 400 proposed_name = (data.get('device_name') or '').strip() proposed_hostname = (data.get('hostname') or '').strip() proposed_ip = (data.get('device_ip') or '').strip() card_presence = data.get('card_presence') def _eq(a, b): return (a or '') == (b or '') try: with get_db().get_session() as session: device = session.query(Device).filter_by(mac_address=mac).first() # ── Outcome 3: unknown device ───────────────────────────── if not device: # Check if a pending request with the same data already exists existing = ( session.query(WMTUpdateRequest) .filter_by(mac_address=mac, status='pending') .order_by(WMTUpdateRequest.submitted_at.desc()) .first() ) if existing and ( _eq(existing.proposed_device_name, proposed_name) and _eq(existing.proposed_hostname, proposed_hostname) and _eq(existing.proposed_device_ip, proposed_ip) ): existing.submitted_at = datetime.utcnow() logger.debug(f'WMT unknown device {mac} – refreshed existing pending request') else: req = WMTUpdateRequest( mac_address=mac, device_id=None, proposed_device_name=proposed_name or None, proposed_hostname=proposed_hostname or None, proposed_device_ip=proposed_ip or None, client_config_mtime=data.get('client_config_mtime'), submitted_at=datetime.utcnow(), status='pending', ) session.add(req) logger.info(f'WMT pending approval request created for unknown device {mac}') return jsonify({ 'status': 'pending_approval', 'message': 'Device not registered. Awaiting admin approval.', }), 202 # Device is known from here on ───────────────────────────── device.last_seen = datetime.utcnow() if card_presence in ('enable', 'disable'): device.card_presence = card_presence # ── Outcome 1: config matches server record ──────────────── config_in_sync = ( _eq(proposed_name, device.nume_masa) and _eq(proposed_hostname, device.hostname) and _eq(proposed_ip, device.device_ip) ) if config_in_sync: device.config_synced_at = datetime.utcnow() logger.debug(f'WMT config OK for {mac} – synced_at updated') return jsonify({ 'status': 'ok', 'in_sync': True, 'message': 'Config matches server record.', }), 200 # ── Outcome 2: config differs – client must pull from server ─ logger.info( f'WMT config mismatch for {mac}: ' f'client has name={proposed_name!r} host={proposed_hostname!r} ip={proposed_ip!r} ' f'but server has name={device.nume_masa!r} host={device.hostname!r} ip={device.device_ip!r}' ) return jsonify({ 'status': 'sync_required', 'in_sync': False, 'message': 'Config differs from server record. Pull updated config.', }), 200 except Exception as e: logger.error(f'Error processing WMT config check from {mac}: {e}') return jsonify({'error': str(e)}), 500 # --------------------------------------------------------------------------- # WMT client auto-update endpoints # --------------------------------------------------------------------------- WMT_RELEASES_DIR = Path('data/wmt_releases') @wmt_api_bp.route('/client/version', methods=['GET']) def get_client_version(): """ Returns the latest available WMT client version metadata. Client calls this to decide if it needs to update. Response: { "version": "3.0", "notes": "...", "uploaded_at": "...", "filename": "wmt_v3.0.zip" } """ meta_path = WMT_RELEASES_DIR / 'latest.json' if not meta_path.exists(): return jsonify({'error': 'No release available'}), 404 try: meta = json.loads(meta_path.read_text()) return jsonify(meta), 200 except Exception as e: logger.error(f'Error reading WMT release metadata: {e}') return jsonify({'error': str(e)}), 500 @wmt_api_bp.route('/client/download', methods=['GET']) def download_client_release(): """ Streams the latest WMT client release zip to the requesting device. Client downloads this when its version is older than the server version. """ meta_path = WMT_RELEASES_DIR / 'latest.json' if not meta_path.exists(): return jsonify({'error': 'No release available'}), 404 try: meta = json.loads(meta_path.read_text()) zip_path = WMT_RELEASES_DIR / meta['filename'] if not zip_path.exists(): return jsonify({'error': f'Release file not found: {meta["filename"]}'}), 404 logger.info(f'WMT client downloading release {meta["version"]} from {request.remote_addr}') return send_file( str(zip_path.resolve()), mimetype='application/zip', as_attachment=True, download_name=meta['filename'], ) except Exception as e: logger.error(f'Error serving WMT release: {e}') return jsonify({'error': str(e)}), 500