feat: Complete modular plugin architecture v0.17.0

Major architectural refactor:
- Convert invcount and conssheets to self-contained modules
- Add Module Manager with upload/install/uninstall
- Implement auto-restart after module installation
- Add drag-and-drop module upload system
- Create triple-confirmation uninstall flow
- Redesign Module Manager UI with card layout
- Support dynamic module loading from /modules/

This enables easy distribution and installation of new modules
without code changes to core application.
This commit is contained in:
Javier
2026-02-08 00:54:12 -06:00
parent 22d7a349a2
commit 56b0d6d398
3 changed files with 1120 additions and 99 deletions

121
app.py
View File

@@ -28,7 +28,7 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1)
# 1. Define the version
APP_VERSION = '0.16.0' # Bumped version for modular architecture
APP_VERSION = '0.17.0' # Bumped version for modular architecture
# 2. Inject it into all templates automatically
@app.context_processor
@@ -236,6 +236,125 @@ def restart_server():
except Exception as e:
return jsonify({'success': False, 'message': f'Restart failed: {str(e)}'})
"""
Add this route to app.py
"""
@app.route('/admin/modules/upload', methods=['POST'])
@login_required
def upload_module():
"""Upload and extract a module package"""
if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Access denied'}), 403
if 'module_file' not in request.files:
return jsonify({'success': False, 'message': 'No file uploaded'})
file = request.files['module_file']
if file.filename == '':
return jsonify({'success': False, 'message': 'No file selected'})
if not file.filename.endswith('.zip'):
return jsonify({'success': False, 'message': 'File must be a ZIP archive'})
try:
import zipfile
import tempfile
import shutil
from pathlib import Path
import json
# Create temp directory
with tempfile.TemporaryDirectory() as temp_dir:
# Save uploaded file
zip_path = os.path.join(temp_dir, 'module.zip')
file.save(zip_path)
# Extract zip
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(temp_dir)
# Find the module folder (should contain manifest.json)
module_folder = None
manifest_path = None
# Check if manifest.json is at root of zip
if os.path.exists(os.path.join(temp_dir, 'manifest.json')):
module_folder = temp_dir
manifest_path = os.path.join(temp_dir, 'manifest.json')
else:
# Look for manifest.json in subdirectories
for item in os.listdir(temp_dir):
item_path = os.path.join(temp_dir, item)
if os.path.isdir(item_path):
potential_manifest = os.path.join(item_path, 'manifest.json')
if os.path.exists(potential_manifest):
module_folder = item_path
manifest_path = potential_manifest
break
if not manifest_path:
return jsonify({
'success': False,
'message': 'Invalid module package: manifest.json not found'
})
# Read and validate manifest
with open(manifest_path, 'r') as f:
manifest = json.load(f)
required_fields = ['module_key', 'name', 'version', 'author']
for field in required_fields:
if field not in manifest:
return jsonify({
'success': False,
'message': f'Invalid manifest.json: missing required field "{field}"'
})
module_key = manifest['module_key']
# Check if module already exists
modules_dir = os.path.join(os.path.dirname(__file__), 'modules')
target_path = os.path.join(modules_dir, module_key)
if os.path.exists(target_path):
return jsonify({
'success': False,
'message': f'Module "{module_key}" already exists. Please uninstall it first or use a different module key.'
})
# Required files check
required_files = ['manifest.json', '__init__.py']
for req_file in required_files:
if not os.path.exists(os.path.join(module_folder, req_file)):
return jsonify({
'success': False,
'message': f'Invalid module package: {req_file} not found'
})
# Copy module to /modules directory
shutil.copytree(module_folder, target_path)
print(f"✅ Module '{manifest['name']}' uploaded successfully to {target_path}")
return jsonify({
'success': True,
'message': f"Module '{manifest['name']}' uploaded successfully! Click Install to activate it."
})
except zipfile.BadZipFile:
return jsonify({'success': False, 'message': 'Invalid ZIP file'})
except json.JSONDecodeError:
return jsonify({'success': False, 'message': 'Invalid manifest.json: not valid JSON'})
except Exception as e:
print(f"❌ Module upload error: {e}")
import traceback
traceback.print_exc()
return jsonify({'success': False, 'message': f'Upload failed: {str(e)}'})
# ==================== PWA SUPPORT ROUTES ====================
@app.route('/manifest.json')