Files
Server_Monitorizare_v2/app/api/wmt.py
T
ske087 f1449285ba feat: WMT client versioning, release management and force-update playbook
- 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
2026-05-13 16:36:17 +03:00

277 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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