feat: Implement modular plugin architecture

- Convert invcount to self-contained module
- Add Module Manager for install/uninstall
- Create module_registry database table
- Support hot-reloading of modules
- Move data imports into invcount module
- Update all templates and routes to new structure

Version bumped to 0.16.0
This commit is contained in:
Javier
2026-02-07 01:47:49 -06:00
parent 2a649fdbcc
commit 406219547d
35 changed files with 3887 additions and 492 deletions

184
app.py
View File

@@ -1,7 +1,7 @@
"""
ScanLook - Inventory Management System
ScanLook - Modular Inventory Management System
Flask Application
Production-Ready Release
Production-Ready Release with Module System
"""
from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify, send_from_directory
from werkzeug.security import check_password_hash
@@ -13,21 +13,11 @@ app = Flask(__name__)
# Now import your custom modules
from db import query_db, execute_db, get_db
from blueprints.data_imports import data_imports_bp
from blueprints.users import users_bp
from blueprints.sessions import sessions_bp
from blueprints.admin_locations import admin_locations_bp
from blueprints.counting import counting_bp
from blueprints.cons_sheets import cons_sheets_bp
from utils import login_required
# Register Blueprints
app.register_blueprint(data_imports_bp)
# Register Core Blueprints (non-modular)
app.register_blueprint(users_bp)
app.register_blueprint(sessions_bp)
app.register_blueprint(admin_locations_bp)
app.register_blueprint(counting_bp)
app.register_blueprint(cons_sheets_bp)
# V1.0: Use environment variable for production, fallback to demo key for development
app.secret_key = os.environ.get('SCANLOOK_SECRET_KEY', 'scanlook-demo-key-replace-for-production')
@@ -38,7 +28,7 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1)
# 1. Define the version
APP_VERSION = '0.15.0'
APP_VERSION = '0.16.0' # Bumped version for modular architecture
# 2. Inject it into all templates automatically
@app.context_processor
@@ -53,11 +43,17 @@ if not os.path.exists(db_path):
init_database()
create_default_users()
print("Database initialized!")
print("📦 Install modules from /admin/modules")
# Run migrations to apply any pending database changes
from migrations import run_migrations
run_migrations()
# Load and register active modules
from module_manager import get_module_manager
module_manager = get_module_manager()
module_manager.load_active_modules(app)
# ==================== ROUTES: AUTHENTICATION ====================
@@ -119,49 +115,124 @@ def home():
return render_template('home.html', modules=modules)
# ==================== ROUTES: DASHBOARD ====================
# ==================== ROUTES: ADMIN DASHBOARD ====================
@app.route('/admin')
@login_required
def admin_dashboard():
"""Main dashboard - different views for admin vs staff"""
"""Admin dashboard - shows all available modules"""
role = session.get('role')
if role in ['owner', 'admin']:
# Admin dashboard
show_archived = request.args.get('show_archived', '0') == '1'
if show_archived:
# Show all sessions (active and archived)
sessions_list = query_db('''
SELECT s.*, u.full_name as created_by_name,
COUNT(DISTINCT lc.location_count_id) as total_locations,
SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations,
SUM(CASE WHEN lc.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress_locations
FROM CountSessions s
LEFT JOIN Users u ON s.created_by = u.user_id
LEFT JOIN LocationCounts lc ON s.session_id = lc.session_id
WHERE s.status IN ('active', 'archived')
GROUP BY s.session_id
ORDER BY s.status ASC, s.created_timestamp DESC
''')
else:
# Show only active sessions
sessions_list = query_db('''
SELECT s.*, u.full_name as created_by_name,
COUNT(DISTINCT lc.location_count_id) as total_locations,
SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations,
SUM(CASE WHEN lc.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress_locations
FROM CountSessions s
LEFT JOIN Users u ON s.created_by = u.user_id
LEFT JOIN LocationCounts lc ON s.session_id = lc.session_id
WHERE s.status = 'active'
GROUP BY s.session_id
ORDER BY s.created_timestamp DESC
''')
return render_template('admin_dashboard.html', sessions=sessions_list, show_archived=show_archived)
if role not in ['owner', 'admin']:
flash('Access denied. Admin role required.', 'danger')
return redirect(url_for('home'))
# Get modules this user has access to
user_id = session.get('user_id')
modules = query_db('''
SELECT m.module_id, m.module_name, m.module_key, m.description, m.icon
FROM Modules m
JOIN UserModules um ON m.module_id = um.module_id
WHERE um.user_id = ? AND m.is_active = 1
ORDER BY m.display_order
''', [user_id])
return render_template('admin_dashboard.html', modules=modules)
# ==================== MODULE MANAGER UI ====================
@app.route('/admin/modules')
@login_required
def module_manager_ui():
"""Module manager interface for admins"""
if session.get('role') not in ['owner', 'admin']:
flash('Access denied. Admin role required.', 'danger')
return redirect(url_for('home'))
modules = module_manager.scan_available_modules()
return render_template('module_manager.html', modules=modules)
@app.route('/admin/modules/<module_key>/install', methods=['POST'])
@login_required
def install_module(module_key):
"""Install a module"""
if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Access denied'}), 403
result = module_manager.install_module(module_key)
# Hot-reload: Register the blueprint immediately if installation succeeded
if result['success']:
try:
from pathlib import Path
import importlib.util
import sys
module = module_manager.get_module_by_key(module_key)
if module:
init_path = Path(module['path']) / '__init__.py'
# Import the module
spec = importlib.util.spec_from_file_location(
f"modules.{module_key}",
init_path
)
module_package = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module_package
spec.loader.exec_module(module_package)
# Create and register blueprint
if hasattr(module_package, 'create_blueprint'):
blueprint = module_package.create_blueprint()
app.register_blueprint(blueprint)
print(f"🔥 Hot-loaded: {module['name']} at {module.get('routes_prefix')}")
result['message'] += ' (Module loaded - no restart needed!)'
else:
print(f"⚠️ Module {module_key} missing create_blueprint()")
result['message'] += ' (Restart required - missing create_blueprint)'
else:
print(f"⚠️ Could not find module {module_key} after installation")
result['message'] += ' (Restart required - module not found)'
except Exception as e:
print(f"❌ Hot-reload failed for {module_key}: {e}")
import traceback
traceback.print_exc()
result['message'] += f' (Restart required - hot-reload failed)'
return jsonify(result)
@app.route('/admin/modules/<module_key>/uninstall', methods=['POST'])
@login_required
def uninstall_module(module_key):
"""Uninstall a module"""
if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Access denied'}), 403
result = module_manager.uninstall_module(module_key, drop_tables=True)
return jsonify(result)
@app.route('/admin/modules/<module_key>/activate', methods=['POST'])
@login_required
def activate_module(module_key):
"""Activate a module"""
if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Access denied'}), 403
result = module_manager.activate_module(module_key)
return jsonify(result)
@app.route('/admin/modules/<module_key>/deactivate', methods=['POST'])
@login_required
def deactivate_module(module_key):
"""Deactivate a module"""
if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Access denied'}), 403
result = module_manager.deactivate_module(module_key)
return jsonify(result)
# ==================== PWA SUPPORT ROUTES ====================
@@ -204,6 +275,19 @@ def whatami():
"""
@app.route('/debug/routes')
@login_required
def list_routes():
"""Debug: List all registered routes"""
if session.get('role') not in ['owner', 'admin']:
return "Access denied", 403
routes = []
for rule in app.url_map.iter_rules():
routes.append(f"{rule.endpoint}: {rule.rule}")
return "<br>".join(sorted(routes))
# ==================== RUN APPLICATION ====================
if __name__ == '__main__':