"""Applications API endpoints.""" from flask import Blueprint, request from flask_jwt_extended import jwt_required from shopdb.extensions import db from shopdb.core.models import ( Application, AppVersion, AuditLog ) from shopdb.utils.responses import ( success_response, error_response, paginated_response, ErrorCodes ) from shopdb.utils.pagination import get_pagination_params, paginate_query from shopdb.utils.import_mode import apply_import_timestamps def _computer_models(): """Lazily import the computers plugin models, or None if unavailable. Application install-tracking is a join over the computers plugin's tables. Importing lazily keeps the applications API importable when the computers plugin is absent or disabled. """ try: from plugins.computers.models import Computer, ComputerInstalledApp return Computer, ComputerInstalledApp except ImportError: return None def _installed_count(appid): """Count active installs of an app, 0 when the computers plugin is absent.""" models = _computer_models() if not models: return 0 _, ComputerInstalledApp = models return ComputerInstalledApp.query.filter_by(appid=appid, isactive=True).count() def _require_computer_models(): """Resolve (Computer, ComputerInstalledApp) or a 503 response tuple. Usage: `models, err = _require_computer_models(); if err: return err`. """ models = _computer_models() if not models: return None, error_response( ErrorCodes.INTERNAL_ERROR, 'Install tracking requires the computers plugin', http_code=503) return models, None from shopdb.utils.authz import require_permission applications_bp = Blueprint('applications', __name__) @applications_bp.route('', methods=['GET']) @jwt_required(optional=True) def list_applications(): """List all applications.""" page, per_page = get_pagination_params(request) query = Application.query if request.args.get('active', 'true').lower() != 'false': query = query.filter(Application.isactive == True) # Filter out hidden unless specifically requested if request.args.get('showhidden', 'false').lower() != 'true': query = query.filter(Application.ishidden == False) # Filter by installable if request.args.get('installable') is not None: installable = request.args.get('installable').lower() == 'true' query = query.filter(Application.isinstallable == installable) # Exact-match natural-key lookup for idempotent import (app name). if exactappname := request.args.get('appname'): query = query.filter(Application.appname == exactappname) if search := request.args.get('search'): query = query.filter( db.or_( Application.appname.ilike(f'%{search}%'), Application.appdescription.ilike(f'%{search}%') ) ) query = query.order_by(Application.appname) items, total = paginate_query(query, page, per_page) data = [] for app in items: # to_dict already flattens supportteamname/teamurl/contacts. app_dict = app.to_dict() app_dict['installedcount'] = _installed_count(app.appid) data.append(app_dict) return paginated_response(data, page, per_page, total) @applications_bp.route('/', methods=['GET']) @jwt_required(optional=True) def get_application(app_id: int): """Get a single application with details.""" app = db.session.get(Application, app_id) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) data = app.to_dict() data['versions'] = [v.to_dict() for v in app.versions.filter_by(isactive=True).order_by(AppVersion.version.desc()).all()] data['installedcount'] = _installed_count(app.appid) return success_response(data) @applications_bp.route('', methods=['POST']) @jwt_required() @require_permission('applications.create') def create_application(): """Create a new application.""" data = request.get_json() if not data or not data.get('appname'): return error_response(ErrorCodes.VALIDATION_ERROR, 'appname is required') if Application.query.filter_by(appname=data['appname']).first(): return error_response( ErrorCodes.CONFLICT, f"Application '{data['appname']}' already exists", http_code=409 ) app = Application( appname=data['appname'], appdescription=data.get('appdescription'), supportteamid=data.get('supportteamid'), isinstallable=data.get('isinstallable', False), applicationnotes=data.get('applicationnotes'), installpath=data.get('installpath'), applicationlink=data.get('applicationlink'), documentationpath=data.get('documentationpath'), ishidden=data.get('ishidden', False), isprinter=data.get('isprinter', False), islicenced=data.get('islicenced', False), isrequired=data.get('isrequired', False), image=data.get('image') ) db.session.add(app) apply_import_timestamps(app, data) db.session.flush() AuditLog.log('created', 'Application', entityid=app.appid, entityname=app.appname) db.session.commit() return success_response(app.to_dict(), message='Application created', http_code=201) @applications_bp.route('/', methods=['PUT']) @jwt_required() @require_permission('applications.edit') def update_application(app_id: int): """Update an application.""" app = db.session.get(Application, app_id) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) data = request.get_json() if not data: return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') if 'appname' in data and data['appname'] != app.appname: if Application.query.filter_by(appname=data['appname']).first(): return error_response( ErrorCodes.CONFLICT, f"Application '{data['appname']}' already exists", http_code=409 ) fields = [ 'appname', 'appdescription', 'supportteamid', 'isinstallable', 'applicationnotes', 'installpath', 'applicationlink', 'documentationpath', 'ishidden', 'isprinter', 'islicenced', 'isrequired', 'image', 'isactive' ] changes = {} for key in fields: if key in data: old_val = getattr(app, key) new_val = data[key] if old_val != new_val: changes[key] = {'old': old_val, 'new': new_val} setattr(app, key, data[key]) if changes: AuditLog.log('updated', 'Application', entityid=app.appid, entityname=app.appname, changes=changes) apply_import_timestamps(app, data) db.session.commit() return success_response(app.to_dict(), message='Application updated') @applications_bp.route('/', methods=['DELETE']) @jwt_required() @require_permission('applications.delete') def delete_application(app_id: int): """Delete (deactivate) an application.""" app = db.session.get(Application, app_id) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) app.isactive = False AuditLog.log('deleted', 'Application', entityid=app.appid, entityname=app.appname) db.session.commit() return success_response(message='Application deleted') # ---- Versions ---- @applications_bp.route('//versions', methods=['GET']) @jwt_required(optional=True) def list_versions(app_id: int): """List all versions for an application.""" app = db.session.get(Application, app_id) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) versions = app.versions.filter_by(isactive=True).order_by(AppVersion.version.desc()).all() return success_response([v.to_dict() for v in versions]) @applications_bp.route('//versions', methods=['POST']) @jwt_required() @require_permission('applications.create') def create_version(app_id: int): """Create a new version for an application.""" app = db.session.get(Application, app_id) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) data = request.get_json() if not data or not data.get('version'): return error_response(ErrorCodes.VALIDATION_ERROR, 'version is required') if AppVersion.query.filter_by(appid=app_id, version=data['version']).first(): return error_response( ErrorCodes.CONFLICT, f"Version '{data['version']}' already exists for this application", http_code=409 ) version = AppVersion( appid=app_id, version=data['version'], releasedate=data.get('releasedate'), notes=data.get('notes') ) db.session.add(version) # Preserve the legacy release/added timestamps in import mode (no-op # otherwise), so imported version history keeps its original dates. apply_import_timestamps(version, data) db.session.commit() return success_response(version.to_dict(), message='Version created', http_code=201) # ---- Computers with this app installed ---- @applications_bp.route('//installed', methods=['GET']) @jwt_required(optional=True) def list_installed_machines(app_id: int): """List all computers that have this application installed.""" app = db.session.get(Application, app_id) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) models, err = _require_computer_models() if err: return err _, ComputerInstalledApp = models installed = ComputerInstalledApp.query.filter_by( appid=app_id, isactive=True).all() data = [] for i in installed: comp = i.computer version = i.installedversion if not version and i.appversion: version = i.appversion.version item = { 'id': i.id, 'computerid': i.computerid, 'version': version, } if comp: item['computer'] = { 'computerid': comp.computerid, 'assetnumber': comp.asset.assetnumber if comp.asset else None, 'hostname': comp.hostname, } data.append(item) return success_response(data) # ---- Installed Apps (per computer) ---- @applications_bp.route('/machines/', methods=['GET']) @jwt_required(optional=True) def list_machine_applications(machine_id: int): """List all applications installed on a computer.""" models, err = _require_computer_models() if err: return err Computer, ComputerInstalledApp = models comp = db.session.get(Computer, machine_id) if not comp: return error_response(ErrorCodes.NOT_FOUND, 'Computer not found', http_code=404) installed = comp.installedapps.filter_by(isactive=True).all() return success_response([i.to_dict() for i in installed]) @applications_bp.route('/machines/', methods=['POST']) @jwt_required() @require_permission('applications.create') def install_application(machine_id: int): """Install an application on a computer.""" models, err = _require_computer_models() if err: return err Computer, ComputerInstalledApp = models comp = db.session.get(Computer, machine_id) if not comp: return error_response(ErrorCodes.NOT_FOUND, 'Computer not found', http_code=404) data = request.get_json() if not data or not data.get('appid'): return error_response(ErrorCodes.VALIDATION_ERROR, 'appid is required') app = db.session.get(Application, data['appid']) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) existing = ComputerInstalledApp.query.filter_by( computerid=machine_id, appid=data['appid'] ).first() if existing: if existing.isactive: return error_response( ErrorCodes.CONFLICT, 'Application already installed on this computer', http_code=409 ) existing.isactive = True existing.appversionid = data.get('appversionid') existing.installeddate = db.func.now() db.session.commit() return success_response(existing.to_dict(), message='Application reinstalled') installed = ComputerInstalledApp( computerid=machine_id, appid=data['appid'], appversionid=data.get('appversionid') ) db.session.add(installed) db.session.commit() return success_response(installed.to_dict(), message='Application installed', http_code=201) @applications_bp.route('/machines//', methods=['DELETE']) @jwt_required() @require_permission('applications.delete') def uninstall_application(machine_id: int, app_id: int): """Uninstall an application from a computer.""" models, err = _require_computer_models() if err: return err _, ComputerInstalledApp = models installed = ComputerInstalledApp.query.filter_by( computerid=machine_id, appid=app_id, isactive=True ).first() if not installed: return error_response(ErrorCodes.NOT_FOUND, 'Application not installed on this computer', http_code=404) installed.isactive = False db.session.commit() return success_response(message='Application uninstalled') @applications_bp.route('/machines//', methods=['PUT']) @jwt_required() @require_permission('applications.edit') def update_installed_app(machine_id: int, app_id: int): """Update installed application (e.g., change version).""" models, err = _require_computer_models() if err: return err _, ComputerInstalledApp = models installed = ComputerInstalledApp.query.filter_by( computerid=machine_id, appid=app_id, isactive=True ).first() if not installed: return error_response(ErrorCodes.NOT_FOUND, 'Application not installed on this computer', http_code=404) data = request.get_json() if not data: return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') if 'appversionid' in data: installed.appversionid = data['appversionid'] db.session.commit() return success_response(installed.to_dict(), message='Installation updated') # Support teams + contacts now live in the supportteams blueprint # (/api/supportteams), replacing the legacy appowners pair.