f1449285ba
- api/wmt.py: add GET /api/wmt/client/version and GET /api/wmt/client/download endpoints; rewrite submit_update_request with dedup logic - web/wmt.py: add releases, releases_upload, releases_delete, releases_build routes; build-from-folder excludes hidden/data/venv/pyc files - web/main.py: admin per-device delete route; clear-device-logs route; pass devices list to admin template - templates/wmt/releases.html: new release management page (current release info, upload form, build-from-folder card) - templates/admin.html: replace nuclear clear-devices with clear-logs + per-device delete table - templates/base.html: add Client Releases nav link in WMT sidebar section - templates/ansible/execute.html: add Update WMT Code playbook card - ansible/playbooks/update_wmt_code.yml: rsync WMT_project to clients excluding data/; backs up app.py; restarts wmt service - ansible_service.py: register update_wmt_code description - .gitignore: whitelist update_wmt_code.yml
277 lines
11 KiB
Python
277 lines
11 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 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
|