303 lines
12 KiB
Python
303 lines
12 KiB
Python
"""
|
||
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=<mac_address>
|
||
|
||
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/<mac_address>', 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 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/<mac> 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
|