added gunicorn and updated to the last version of server monitorizare
This commit is contained in:
@@ -2,8 +2,10 @@
|
||||
WMT (Workstation Management Terminal) configuration API
|
||||
Handles config distribution and device update requests from WMT clients.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
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
|
||||
@@ -96,8 +98,8 @@ def get_device_config(mac_address):
|
||||
_, device_ts, latest_ts = _latest_config_ts(session, mac)
|
||||
|
||||
payload = {
|
||||
# Global settings
|
||||
'chrome_url': global_cfg.chrome_url,
|
||||
# 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,
|
||||
@@ -125,7 +127,25 @@ def get_device_config(mac_address):
|
||||
@wmt_api_bp.route('/config/update_request', methods=['POST'])
|
||||
def submit_update_request():
|
||||
"""
|
||||
WMT client sends current device info as an update request for admin approval.
|
||||
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:
|
||||
{
|
||||
@@ -133,7 +153,8 @@ def submit_update_request():
|
||||
"device_name": "Masa-01",
|
||||
"hostname": "rpi-masa01",
|
||||
"device_ip": "192.168.1.100",
|
||||
"client_config_mtime": "2026-04-22T09:30:00" // optional
|
||||
"card_presence": "enable",
|
||||
"client_config_mtime": "2026-04-22T09:30:00"
|
||||
}
|
||||
"""
|
||||
if not request.is_json:
|
||||
@@ -144,31 +165,138 @@ def submit_update_request():
|
||||
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()
|
||||
|
||||
req = WMTUpdateRequest(
|
||||
mac_address=mac,
|
||||
device_id=device.id if device else None,
|
||||
proposed_device_name=data.get('device_name'),
|
||||
proposed_hostname=data.get('hostname'),
|
||||
proposed_device_ip=data.get('device_ip'),
|
||||
client_config_mtime=data.get('client_config_mtime'),
|
||||
submitted_at=datetime.utcnow(),
|
||||
status='pending',
|
||||
# ── 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)
|
||||
)
|
||||
session.add(req)
|
||||
|
||||
# Update device last_seen
|
||||
if device:
|
||||
device.last_seen = datetime.utcnow()
|
||||
# card_presence is a device capability flag – update directly (no approval needed)
|
||||
if data.get('card_presence') in ('enable', 'disable'):
|
||||
device.card_presence = data['card_presence']
|
||||
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
|
||||
|
||||
logger.info(f'WMT update request received from {mac}')
|
||||
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}')
|
||||
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
|
||||
|
||||
@@ -48,9 +48,11 @@ class Device(Base):
|
||||
|
||||
# WMT (Workstation Management Terminal) integration fields
|
||||
mac_address = Column(String(17), unique=True, nullable=True, index=True)
|
||||
config_updated_at = Column(DateTime)
|
||||
config_updated_at = Column(DateTime) # set by admin when pushing new config
|
||||
config_synced_at = Column(DateTime) # set by server when client confirms in-sync
|
||||
info_reviewed_at = Column(DateTime, default=lambda: datetime(1970, 1, 1))
|
||||
card_presence = Column(String(10), default='enable')
|
||||
custom_chrome_url = Column(String(500), nullable=True) # per-device production URL override (overrides WMTGlobalConfig.chrome_url)
|
||||
|
||||
# Relationships
|
||||
logs = relationship("LogEntry", back_populates="device")
|
||||
|
||||
@@ -737,6 +737,7 @@ class AnsibleService:
|
||||
'system_health': 'Check system health and monitoring status',
|
||||
'maintenance_mode': 'Put devices in maintenance mode',
|
||||
'distribute_ssh_keys': 'Push server public key to all devices using password auth',
|
||||
'update_wmt_code': 'Push WMT_project code to clients from controller (preserves data/)',
|
||||
}
|
||||
return descriptions.get(playbook_name, f'Execute {playbook_name} playbook')
|
||||
|
||||
|
||||
@@ -129,10 +129,20 @@ def execute():
|
||||
seen.add(h['hostname'])
|
||||
|
||||
settings = ansible_service.load_settings()
|
||||
|
||||
# Discover custom playbooks (exclude built-ins that have dedicated buttons)
|
||||
_builtin_names = {'update_devices', 'restart_service', 'distribute_ssh_keys', 'system_health'}
|
||||
custom_playbooks = []
|
||||
if ansible_service.playbook_dir.exists():
|
||||
for _f in sorted(ansible_service.playbook_dir.glob('*.yml')):
|
||||
if _f.stem.lower() not in _builtin_names:
|
||||
custom_playbooks.append({'name': _f.stem, 'filename': _f.name})
|
||||
|
||||
return render_template('ansible/execute.html',
|
||||
inventory=inventory_data,
|
||||
all_inv_hosts=all_inv_hosts,
|
||||
preselect_playbook=preselect,
|
||||
custom_playbooks=custom_playbooks,
|
||||
use_password_auth=settings.get('use_password_auth', False))
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading execute form: {e}")
|
||||
@@ -141,6 +151,7 @@ def execute():
|
||||
inventory={'groups': {}},
|
||||
all_inv_hosts=[],
|
||||
preselect_playbook='',
|
||||
custom_playbooks=[],
|
||||
use_password_auth=False)
|
||||
|
||||
elif request.method == 'POST':
|
||||
@@ -462,7 +473,7 @@ def playbook_content():
|
||||
from pathlib import Path
|
||||
requested_path = Path(playbook_path)
|
||||
if not requested_path.is_absolute():
|
||||
requested_path = ansible_service.playbook_dir / requested_path
|
||||
requested_path = Path.cwd() / requested_path
|
||||
|
||||
# Ensure path is within playbook directory
|
||||
try:
|
||||
|
||||
@@ -34,7 +34,7 @@ def devices():
|
||||
with get_db().get_session() as session:
|
||||
devices = session.query(Device).order_by(Device.last_seen.desc()).all()
|
||||
|
||||
# Get log count per device
|
||||
# Log count per device
|
||||
device_log_counts = {}
|
||||
for device in devices:
|
||||
log_count = session.query(LogEntry).filter_by(device_id=device.id).count()
|
||||
@@ -42,14 +42,22 @@ def devices():
|
||||
|
||||
pending_count = session.query(WMTUpdateRequest).filter_by(status='pending').count()
|
||||
|
||||
# Pending approval requests mapped by mac_address for per-device badge
|
||||
pending_requests = session.query(WMTUpdateRequest).filter_by(status='pending').all()
|
||||
pending_by_mac = {}
|
||||
for req in pending_requests:
|
||||
pending_by_mac[req.mac_address] = pending_by_mac.get(req.mac_address, 0) + 1
|
||||
|
||||
return render_template('device_management.html',
|
||||
devices=devices,
|
||||
device_log_counts=device_log_counts,
|
||||
pending_count=pending_count)
|
||||
pending_count=pending_count,
|
||||
pending_by_mac=pending_by_mac)
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading devices: {e}")
|
||||
flash(f'Error loading devices: {e}', 'error')
|
||||
return render_template('device_management.html', devices=[], device_log_counts={}, pending_count=0)
|
||||
return render_template('device_management.html', devices=[], device_log_counts={},
|
||||
pending_count=0, pending_by_mac={})
|
||||
|
||||
@main_bp.route('/device/<int:device_id>')
|
||||
def device_detail(device_id):
|
||||
@@ -516,7 +524,18 @@ def admin():
|
||||
logging.error(f'Admin inventory stats error: {e}')
|
||||
stats['inventory_hosts'] = '?'
|
||||
stats['inventory_groups_yaml'] = '?'
|
||||
return render_template('admin.html', stats=stats)
|
||||
# Pass registered devices for per-device delete UI
|
||||
devices = []
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
devices = [
|
||||
{'id': d.id, 'hostname': d.hostname, 'nume_masa': d.nume_masa,
|
||||
'mac_address': d.mac_address, 'device_ip': d.device_ip}
|
||||
for d in session.query(Device).order_by(Device.nume_masa).all()
|
||||
]
|
||||
except Exception as e:
|
||||
logging.error(f'Admin device list error: {e}')
|
||||
return render_template('admin.html', stats=stats, devices=devices)
|
||||
|
||||
|
||||
@main_bp.route('/admin/clear/logs', methods=['POST'])
|
||||
@@ -532,9 +551,43 @@ def admin_clear_logs():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@main_bp.route('/admin/clear/device-logs', methods=['POST'])
|
||||
def admin_clear_device_logs():
|
||||
"""Delete all log entries from the database (devices stay intact)."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
count = session.query(LogEntry).delete()
|
||||
session.commit()
|
||||
return jsonify({'success': True, 'deleted': count})
|
||||
except Exception as e:
|
||||
logging.error(f'Admin clear device logs error: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@main_bp.route('/admin/delete/device/<int:device_id>', methods=['POST'])
|
||||
def admin_delete_device(device_id):
|
||||
"""Delete a single registered device and its log entries."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
device = session.query(Device).filter_by(id=device_id).first()
|
||||
if not device:
|
||||
return jsonify({'success': False, 'error': 'Device not found'}), 404
|
||||
name = device.nume_masa or device.hostname
|
||||
session.execute(text('DELETE FROM device_inventory_groups WHERE device_id = :id'), {'id': device_id})
|
||||
session.query(LogEntry).filter_by(device_id=device_id).delete()
|
||||
session.query(WMTUpdateRequest).filter_by(device_id=device_id).delete()
|
||||
session.delete(device)
|
||||
session.commit()
|
||||
logging.info(f'Admin deleted device {device_id} ({name})')
|
||||
return jsonify({'success': True, 'name': name})
|
||||
except Exception as e:
|
||||
logging.error(f'Admin delete device {device_id} error: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@main_bp.route('/admin/clear/devices', methods=['POST'])
|
||||
def admin_clear_devices():
|
||||
"""Delete all devices (and their log entries) from the database."""
|
||||
"""Delete ALL devices (and their log entries) from the database."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
session.execute(text('DELETE FROM device_inventory_groups'))
|
||||
|
||||
@@ -3,8 +3,12 @@ WMT management web routes – global settings, device registry, update requests.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, Response
|
||||
from datetime import datetime
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, Response, jsonify
|
||||
from datetime import datetime, timezone
|
||||
from app.models import WMTGlobalConfig, Device, WMTUpdateRequest
|
||||
from config.database_config import get_db
|
||||
import logging
|
||||
@@ -295,6 +299,8 @@ def device_edit(device_id):
|
||||
device.location = request.form.get('location', '').strip() or None
|
||||
device.card_presence = request.form.get('card_presence', 'enable')
|
||||
device.description = request.form.get('notes', '').strip() or None
|
||||
custom_url = request.form.get('custom_chrome_url', '').strip()
|
||||
device.custom_chrome_url = custom_url if custom_url else None
|
||||
device.config_updated_at = datetime.utcnow()
|
||||
device.info_reviewed_at = datetime.utcnow()
|
||||
flash('Device updated.', 'success')
|
||||
@@ -363,3 +369,196 @@ def device_delete(device_id):
|
||||
logger.error(f'WMT device_delete error: {e}')
|
||||
flash(f'Error: {e}', 'error')
|
||||
return redirect(url_for('wmt_web.devices'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WMT Client Release Management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WMT_RELEASES_DIR = Path('data/wmt_releases')
|
||||
|
||||
|
||||
def _read_release_meta():
|
||||
meta_path = WMT_RELEASES_DIR / 'latest.json'
|
||||
if meta_path.exists():
|
||||
try:
|
||||
return json.loads(meta_path.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@wmt_web_bp.route('/releases')
|
||||
def releases():
|
||||
"""WMT client release management page."""
|
||||
meta = _read_release_meta()
|
||||
zip_size = None
|
||||
if meta:
|
||||
zip_path = WMT_RELEASES_DIR / meta.get('filename', '')
|
||||
if zip_path.exists():
|
||||
zip_size = zip_path.stat().st_size
|
||||
return render_template('wmt/releases.html', meta=meta, zip_size=zip_size)
|
||||
|
||||
|
||||
@wmt_web_bp.route('/releases/upload', methods=['POST'])
|
||||
def releases_upload():
|
||||
"""Upload a new WMT client release zip and set it as latest."""
|
||||
f = request.files.get('release_zip')
|
||||
version_str = request.form.get('version', '').strip()
|
||||
notes = request.form.get('notes', '').strip()
|
||||
|
||||
if not f or not f.filename.endswith('.zip'):
|
||||
flash('Please upload a .zip file.', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
if not version_str:
|
||||
flash('Version is required.', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
# Validate version format (digits and dots)
|
||||
if not re.match(r'^\d+(\.\d+)*$', version_str):
|
||||
flash('Version must be in numeric format (e.g. 3.0 or 3.1.2).', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
try:
|
||||
WMT_RELEASES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
filename = f'wmt_v{version_str}.zip'
|
||||
zip_path = WMT_RELEASES_DIR / filename
|
||||
|
||||
# Validate the zip contains app.py
|
||||
data = f.read()
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
names = zf.namelist()
|
||||
if 'app.py' not in names:
|
||||
flash('Invalid release: zip must contain app.py at the root.', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
except zipfile.BadZipFile:
|
||||
flash('Uploaded file is not a valid zip archive.', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
zip_path.write_bytes(data)
|
||||
|
||||
meta = {
|
||||
'version': version_str,
|
||||
'notes': notes,
|
||||
'uploaded_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
'filename': filename,
|
||||
}
|
||||
(WMT_RELEASES_DIR / 'latest.json').write_text(json.dumps(meta, indent=2))
|
||||
|
||||
logger.info(f'New WMT release uploaded: v{version_str} ({filename})')
|
||||
flash(f'Release v{version_str} uploaded and set as latest.', 'success')
|
||||
except Exception as e:
|
||||
logger.error(f'WMT release upload error: {e}')
|
||||
flash(f'Upload error: {e}', 'error')
|
||||
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
|
||||
@wmt_web_bp.route('/releases/delete', methods=['POST'])
|
||||
def releases_delete():
|
||||
"""Remove the current release zip and metadata."""
|
||||
try:
|
||||
meta = _read_release_meta()
|
||||
if meta:
|
||||
zip_path = WMT_RELEASES_DIR / meta.get('filename', '')
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
meta_path = WMT_RELEASES_DIR / 'latest.json'
|
||||
if meta_path.exists():
|
||||
meta_path.unlink()
|
||||
flash('Release deleted.', 'success')
|
||||
except Exception as e:
|
||||
logger.error(f'WMT release delete error: {e}')
|
||||
flash(f'Error: {e}', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
|
||||
# Patterns excluded when building a release from a local folder
|
||||
_BUILD_EXCLUDE_DIRS = {'.git', '__pycache__', 'data', 'venv', '.venv', 'node_modules'}
|
||||
_BUILD_EXCLUDE_EXTS = {'.pyc', '.pyo', '.bak', '.log', '.swp'}
|
||||
|
||||
|
||||
def _should_exclude(rel_parts):
|
||||
"""Return True if this path should be left out of the release zip."""
|
||||
# Any hidden segment (starts with '.')
|
||||
for part in rel_parts:
|
||||
if part.startswith('.'):
|
||||
return True
|
||||
# Top-level or nested dir exclusion
|
||||
if rel_parts[0] in _BUILD_EXCLUDE_DIRS:
|
||||
return True
|
||||
# Extension exclusion
|
||||
if Path(rel_parts[-1]).suffix.lower() in _BUILD_EXCLUDE_EXTS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@wmt_web_bp.route('/releases/build', methods=['POST'])
|
||||
def releases_build():
|
||||
"""
|
||||
Build a release zip directly from a folder on this server,
|
||||
excluding hidden files, __pycache__, data/, venv/, etc.
|
||||
"""
|
||||
folder_path = request.form.get('folder_path', '/home/pi/Desktop/WMT').strip()
|
||||
version_str = request.form.get('version', '').strip()
|
||||
notes = request.form.get('notes', '').strip()
|
||||
|
||||
if not version_str:
|
||||
flash('Version is required.', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
if not re.match(r'^\d+(\.\d+)*$', version_str):
|
||||
flash('Version must be in numeric format (e.g. 3.0).', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
src = Path(folder_path)
|
||||
if not src.is_dir():
|
||||
flash(f'Folder not found: {folder_path}', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
try:
|
||||
buf = io.BytesIO()
|
||||
file_count = 0
|
||||
has_app_py = False
|
||||
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for abs_path in sorted(src.rglob('*')):
|
||||
if not abs_path.is_file():
|
||||
continue
|
||||
rel = abs_path.relative_to(src)
|
||||
parts = rel.parts
|
||||
if _should_exclude(parts):
|
||||
continue
|
||||
arcname = str(rel)
|
||||
zf.write(abs_path, arcname)
|
||||
file_count += 1
|
||||
if arcname == 'app.py':
|
||||
has_app_py = True
|
||||
|
||||
if not has_app_py:
|
||||
flash(f'No app.py found in {folder_path} — cannot create release.', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
WMT_RELEASES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
filename = f'wmt_v{version_str}.zip'
|
||||
zip_path = WMT_RELEASES_DIR / filename
|
||||
zip_path.write_bytes(buf.getvalue())
|
||||
|
||||
meta = {
|
||||
'version': version_str,
|
||||
'notes': notes,
|
||||
'uploaded_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
'filename': filename,
|
||||
}
|
||||
(WMT_RELEASES_DIR / 'latest.json').write_text(json.dumps(meta, indent=2))
|
||||
|
||||
size_kb = len(buf.getvalue()) // 1024
|
||||
logger.info(f'WMT release built from {folder_path}: v{version_str}, {file_count} files, {size_kb} KB')
|
||||
flash(f'Release v{version_str} built from {folder_path} ({file_count} files, {size_kb} KB).', 'success')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'WMT release build error: {e}')
|
||||
flash(f'Build error: {e}', 'error')
|
||||
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
Reference in New Issue
Block a user