Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.
Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
prefixes, enable toggle. Defaults point at the current
geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
ships as the map default and sites upload their own blueprint.
Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).
Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.
Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
(naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
plugin contract purity) and the USB label page field mapping (both usb
modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.
248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
528 lines
17 KiB
Python
528 lines
17 KiB
Python
"""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, AppOwner, SupportTeam, AuditLog
|
|
)
|
|
from shopdb.utils.responses import (
|
|
success_response,
|
|
error_response,
|
|
paginated_response,
|
|
ErrorCodes
|
|
)
|
|
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
|
|
|
|
|
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, require_role
|
|
|
|
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)
|
|
|
|
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:
|
|
app_dict = app.to_dict()
|
|
if app.supportteam:
|
|
app_dict['supportteam'] = {
|
|
'supportteamid': app.supportteam.supportteamid,
|
|
'teamname': app.supportteam.teamname,
|
|
'teamurl': app.supportteam.teamurl,
|
|
'owner': {
|
|
'appownerid': app.supportteam.owner.appownerid,
|
|
'appowner': app.supportteam.owner.appowner,
|
|
'sso': app.supportteam.owner.sso
|
|
} if app.supportteam.owner else None
|
|
}
|
|
else:
|
|
app_dict['supportteam'] = None
|
|
app_dict['installedcount'] = _installed_count(app.appid)
|
|
data.append(app_dict)
|
|
|
|
return paginated_response(data, page, per_page, total)
|
|
|
|
|
|
@applications_bp.route('/<int:app_id>', 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()
|
|
if app.supportteam:
|
|
data['supportteam'] = {
|
|
'supportteamid': app.supportteam.supportteamid,
|
|
'teamname': app.supportteam.teamname,
|
|
'teamurl': app.supportteam.teamurl,
|
|
'owner': {
|
|
'appownerid': app.supportteam.owner.appownerid,
|
|
'appowner': app.supportteam.owner.appowner,
|
|
'sso': app.supportteam.owner.sso
|
|
} if app.supportteam.owner else None
|
|
}
|
|
else:
|
|
data['supportteam'] = None
|
|
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)
|
|
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('/<int:app_id>', 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)
|
|
|
|
db.session.commit()
|
|
return success_response(app.to_dict(), message='Application updated')
|
|
|
|
|
|
@applications_bp.route('/<int:app_id>', 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('/<int:app_id>/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('/<int:app_id>/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)
|
|
db.session.commit()
|
|
|
|
return success_response(version.to_dict(), message='Version created', http_code=201)
|
|
|
|
|
|
# ---- Computers with this app installed ----
|
|
|
|
@applications_bp.route('/<int:app_id>/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/<int:machine_id>', 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/<int:machine_id>', 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/<int:machine_id>/<int:app_id>', 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/<int:machine_id>/<int:app_id>', 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 ----
|
|
|
|
@applications_bp.route('/supportteams', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def list_support_teams():
|
|
"""List all support teams."""
|
|
teams = SupportTeam.query.filter_by(isactive=True).order_by(SupportTeam.teamname).all()
|
|
data = []
|
|
for team in teams:
|
|
team_dict = team.to_dict()
|
|
team_dict['owner'] = team.owner.appowner if team.owner else None
|
|
data.append(team_dict)
|
|
return success_response(data)
|
|
|
|
|
|
@applications_bp.route('/supportteams', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('applications.create')
|
|
def create_support_team():
|
|
"""Create a new support team."""
|
|
data = request.get_json()
|
|
if not data or not data.get('teamname'):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'teamname is required')
|
|
|
|
team = SupportTeam(
|
|
teamname=data['teamname'],
|
|
teamurl=data.get('teamurl'),
|
|
appownerid=data.get('appownerid')
|
|
)
|
|
|
|
db.session.add(team)
|
|
db.session.commit()
|
|
|
|
return success_response(team.to_dict(), message='Support team created', http_code=201)
|
|
|
|
|
|
# ---- App Owners ----
|
|
|
|
@applications_bp.route('/appowners', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def list_app_owners():
|
|
"""List all application owners."""
|
|
owners = AppOwner.query.filter_by(isactive=True).order_by(AppOwner.appowner).all()
|
|
return success_response([o.to_dict() for o in owners])
|
|
|
|
|
|
@applications_bp.route('/appowners', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('applications.create')
|
|
def create_app_owner():
|
|
"""Create a new application owner."""
|
|
data = request.get_json()
|
|
if not data or not data.get('appowner'):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'appowner is required')
|
|
|
|
owner = AppOwner(
|
|
appowner=data['appowner'],
|
|
sso=data.get('sso'),
|
|
email=data.get('email')
|
|
)
|
|
|
|
db.session.add(owner)
|
|
db.session.commit()
|
|
|
|
return success_response(owner.to_dict(), message='App owner created', http_code=201)
|