""" 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 'chrome_url': 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 current device info. Three outcomes: 1. Device not registered → create pending WMTUpdateRequest for admin approval. 2. Device registered, info UNCHANGED → update last_seen only; no request created. 3. Device registered, info CHANGED → create/refresh a pending WMTUpdateRequest. 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", // optional – applied directly, no approval "client_config_mtime": "2026-04-22T09:30:00" // optional } """ 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): """Compare two values treating None and '' as equivalent.""" return (a or '') == (b or '') try: with get_db().get_session() as session: device = session.query(Device).filter_by(mac_address=mac).first() # Always update last_seen and card_presence (no approval needed) if device: device.last_seen = datetime.utcnow() if card_presence in ('enable', 'disable'): device.card_presence = card_presence # --- Determine whether the proposed info differs from the server record --- if device: info_changed = not ( _eq(proposed_name, device.nume_masa) and _eq(proposed_hostname, device.hostname) and _eq(proposed_ip, device.device_ip) ) else: # Unknown device – always needs admin attention info_changed = True if not info_changed: # Heartbeat only – nothing for admin to review logger.debug(f'WMT heartbeat from {mac} – info unchanged, last_seen updated') return jsonify({'status': 'no_change', 'message': 'Device info matches server record'}), 200 # --- Info changed (or device unknown): avoid duplicate pending requests --- 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) ): # Identical pending request already exists – just refresh its timestamp existing.submitted_at = datetime.utcnow() logger.debug(f'WMT duplicate pending request from {mac} – timestamp refreshed') return jsonify({'status': 'pending', 'message': 'Update request already pending admin review'}), 200 # Create a new update request req = WMTUpdateRequest( mac_address=mac, device_id=device.id if device else 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) reason = 'new device' if not device else 'info changed' logger.info(f'WMT update request created for {mac} ({reason})') return jsonify({'status': 'received', 'message': 'Update request queued for admin review'}), 201 except Exception as e: logger.error(f'Error saving WMT update request 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