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
This commit is contained in:
+199
-2
@@ -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
|
||||
@@ -363,3 +367,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