Initial import: shopdb-flask multi-site asset platform

Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
"""Core API blueprints."""
from .auth import auth_bp
from .assets import assets_bp
from .modeltypes import modeltypes_bp
from .plugins import plugins_bp
from .vendors import vendors_bp
from .models import models_bp
from .businessunits import businessunits_bp
from .locations import locations_bp
from .operatingsystems import operatingsystems_bp
from .dashboard import dashboard_bp
from .dashboarddefaults import dashboarddefaults_bp
from .applications import applications_bp
from .supportteams import supportteams_bp
from .search import search_bp
from .reports import reports_bp
from .collector import collector_bp
from .settings import settings_bp
from .auditlogs import auditlogs_bp
from .users import users_bp
from .customfields import customfields_bp
from .setup import setup_bp
from .pluginui import pluginui_bp
from .apitokens import apitokens_bp
__all__ = [
'auth_bp',
'assets_bp',
'modeltypes_bp',
'plugins_bp',
'vendors_bp',
'models_bp',
'businessunits_bp',
'locations_bp',
'operatingsystems_bp',
'dashboard_bp',
'dashboarddefaults_bp',
'applications_bp',
'supportteams_bp',
'search_bp',
'reports_bp',
'collector_bp',
'settings_bp',
'auditlogs_bp',
'users_bp',
'customfields_bp',
'setup_bp',
'pluginui_bp',
'apitokens_bp',
]

View File

@@ -0,0 +1,171 @@
"""Personal API token management endpoints.
Any authenticated user may manage their OWN tokens; an admin may list or revoke
anyone's. Endpoints are jwt_required (a token must be bootstrapped from a real
login or an existing token). The full secret is returned ONCE, on create.
"""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, current_user
from shopdb.extensions import db
from shopdb.core.models import ApiToken, AuditLog
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_permission
from shopdb.utils.import_mode import parse_import_datetime
apitokens_bp = Blueprint('apitokens', __name__)
def _validate_scopes(scopes, owner):
"""Validate a scopes payload against the token OWNER. Return
(scopelist_or_none, error_response); the second is None when valid.
scopes may be None/absent (unscoped) or a list of permission names. Every
name must exist in the catalog AND be held by the owner (the scope ceiling):
a token can never grant more than its owner holds. Admins hold everything.
"""
if scopes is None:
return None, None
if not isinstance(scopes, list) or not all(isinstance(s, str) for s in scopes):
return None, error_response(
ErrorCodes.VALIDATION_ERROR,
'scopes must be a list of permission names')
unknown = ApiToken.unknown_scope_names(scopes)
if unknown:
return None, error_response(
ErrorCodes.VALIDATION_ERROR,
'Unknown permission names: ' + ', '.join(unknown))
# Scope ceiling: the owner must actually hold each scoped permission.
disallowed = [n for n in scopes if not owner.haspermission(n)]
if disallowed:
return None, error_response(
ErrorCodes.VALIDATION_ERROR,
'Permissions not held by the token owner: ' + ', '.join(disallowed))
return scopes, None
@apitokens_bp.route('', methods=['GET'])
@jwt_required()
def list_apitokens():
"""List the caller's own tokens. Admins may pass ?all=true for everyone's.
Never returns hashes or secrets.
"""
wants_all = request.args.get('all', 'false').lower() == 'true'
is_admin = current_user.hasrole('admin')
query = ApiToken.query
if wants_all and is_admin:
include_owner = True
else:
query = query.filter(ApiToken.userid == current_user.userid)
include_owner = False
query = query.order_by(ApiToken.createddate.desc())
tokens = [t.to_dict(include_owner=include_owner) for t in query.all()]
return success_response(tokens)
@apitokens_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('apitokens.create')
def create_apitoken():
"""Create a token for the caller. Returns the full secret ONCE.
Optional scopes limit the token to a subset of the caller's own
permissions; absent/null means an unscoped token acting as the caller.
"""
data = request.get_json() or {}
name = (data.get('name') or '').strip()
if not name:
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
expiresat = None
if data.get('expiresat'):
expiresat = parse_import_datetime(data.get('expiresat'))
if expiresat is None:
return error_response(ErrorCodes.VALIDATION_ERROR,
'expiresat is not a valid date/datetime')
scopelist, scope_error = _validate_scopes(data.get('scopes'), current_user)
if scope_error is not None:
return scope_error
secret = ApiToken.generate_secret()
token = ApiToken(
userid=current_user.userid,
name=name,
tokenprefix=ApiToken.prefix_of(secret),
tokenhash=ApiToken.hash_secret(secret),
expiresat=expiresat,
)
token.scopelist = scopelist
db.session.add(token)
db.session.flush()
AuditLog.log('created', 'ApiToken', entityid=token.tokenid, entityname=name)
db.session.commit()
result = token.to_dict()
# The secret appears here and NOWHERE else, ever. Not stored, not logged.
result['secret'] = secret
result['warning'] = ('Save this token now. It will not be shown again. '
'Store it somewhere safe.')
return success_response(result, message='Token created', http_code=201)
@apitokens_bp.route('/<int:tokenid>', methods=['PUT'])
@jwt_required()
@require_permission('apitokens.create')
def update_apitoken(tokenid: int):
"""Rename, rescope, or deactivate a token. Own token, or any if admin.
Scopes are validated against the token OWNER's permissions (the ceiling),
not the editor's - so an admin rescoping someone else's token still cannot
grant that owner more than the owner holds.
"""
token = db.session.get(ApiToken, tokenid)
if token is None:
return error_response(ErrorCodes.NOT_FOUND, 'Token not found', http_code=404)
if token.userid != current_user.userid and not current_user.hasrole('admin'):
return error_response(ErrorCodes.FORBIDDEN,
'You may only manage your own tokens', http_code=403)
data = request.get_json() or {}
if 'name' in data:
newname = (data.get('name') or '').strip()
if not newname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'name cannot be empty')
token.name = newname
if 'isactive' in data:
token.isactive = bool(data['isactive'])
if 'scopes' in data:
scopelist, scope_error = _validate_scopes(data.get('scopes'), token.user)
if scope_error is not None:
return scope_error
token.scopelist = scopelist
db.session.commit()
return success_response(token.to_dict(), message='Token updated')
@apitokens_bp.route('/<int:tokenid>', methods=['DELETE'])
@jwt_required()
@require_permission('apitokens.create')
def revoke_apitoken(tokenid: int):
"""Revoke (deactivate) a token. Own token, or any if admin."""
token = db.session.get(ApiToken, tokenid)
if token is None:
return error_response(ErrorCodes.NOT_FOUND, 'Token not found', http_code=404)
if token.userid != current_user.userid and not current_user.hasrole('admin'):
return error_response(ErrorCodes.FORBIDDEN,
'You may only manage your own tokens', http_code=403)
token.isactive = False
AuditLog.log('deleted', 'ApiToken', entityid=token.tokenid, entityname=token.name)
db.session.commit()
return success_response(message='Token revoked')

View File

@@ -0,0 +1,462 @@
"""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 _related_kb(appid):
"""Knowledge Base articles linked to this app, [] when the KB plugin is
absent. Lazy + guarded so core stays decoupled from the plugin."""
try:
from plugins.knowledgebase.models import KnowledgeBase
except ImportError:
return []
rows = KnowledgeBase.query.filter_by(appid=appid, isactive=True).order_by(
KnowledgeBase.shortdescription).all()
return [{'linkid': k.linkid, 'shortdescription': k.shortdescription,
'linkurl': k.linkurl, 'keywords': k.keywords} for k in rows]
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('/<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()
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)
data['knowledgebase'] = _related_kb(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('/<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)
apply_import_timestamps(app, data)
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)
# 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('/<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 + contacts now live in the supportteams blueprint
# (/api/supportteams), replacing the legacy appowners pair.

1170
shopdb/core/api/assets.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,196 @@
"""Audit log API routes."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.core.models import AuditLog
from shopdb.utils.responses import success_response
from shopdb.utils.authz import require_permission
auditlogs_bp = Blueprint('auditlogs', __name__)
def _resolve_full_names(ssos):
"""Best-effort SSO -> full name map for audit rows. Mode-aware, guarded,
degrades to {} so the list still renders if the directory is unreachable."""
wanted = {s for s in ssos if s and str(s).isdigit()}
if not wanted:
return {}
names = {}
# Self-hosted directory first (no external dependency).
try:
from plugins.employees.models import DirectoryEmployee
rows = DirectoryEmployee.query.filter(
DirectoryEmployee.sso.in_(wanted)).all()
for emp in rows:
full = f'{(emp.firstname or "").strip()} {(emp.lastname or "").strip()}'.strip()
if full:
names[str(emp.sso)] = full
except Exception:
pass
missing = wanted - set(names)
if not missing:
return names
# External HR directory for anything still unresolved.
try:
from shopdb.utils.employee_db import employee_connection
conn = employee_connection()
placeholders = ','.join(['%s'] * len(missing))
with conn.cursor() as cur:
cur.execute(
'SELECT SSO, First_Name, Last_Name FROM employees '
f'WHERE SSO IN ({placeholders})', tuple(missing))
for emp in cur.fetchall():
full = f'{(emp["First_Name"] or "").strip()} {(emp["Last_Name"] or "").strip()}'.strip()
if full:
names[str(emp['SSO'])] = full
conn.close()
except Exception:
pass
return names
@auditlogs_bp.route('', methods=['GET'])
@jwt_required()
@require_permission('audit.view')
def list_auditlogs():
"""
List audit logs with filtering and pagination.
Query params:
page: Page number (default 1)
perpage: Items per page (default 50, max 200)
action: Filter by action (created, updated, deleted)
entitytype: Filter by entity type
userid: Filter by user ID
search: Search in entityname or username
from_date: Filter from date (ISO format)
to_date: Filter to date (ISO format)
"""
page = request.args.get('page', 1, type=int)
perpage = min(request.args.get('perpage', 50, type=int), 200)
query = AuditLog.query
# Filters
action = request.args.get('action')
if action:
query = query.filter(AuditLog.action == action)
entitytype = request.args.get('entitytype')
if entitytype:
query = query.filter(AuditLog.entitytype == entitytype)
userid = request.args.get('userid', type=int)
if userid:
query = query.filter(AuditLog.userid == userid)
search = request.args.get('search')
if search:
search_term = f'%{search}%'
query = query.filter(
(AuditLog.entityname.ilike(search_term)) |
(AuditLog.username.ilike(search_term))
)
from_date = request.args.get('from_date')
if from_date:
from datetime import datetime
try:
dt = datetime.fromisoformat(from_date.replace('Z', '+00:00'))
query = query.filter(AuditLog.timestamp >= dt)
except ValueError:
pass
to_date = request.args.get('to_date')
if to_date:
from datetime import datetime
try:
dt = datetime.fromisoformat(to_date.replace('Z', '+00:00'))
query = query.filter(AuditLog.timestamp <= dt)
except ValueError:
pass
# Order by most recent first
query = query.order_by(AuditLog.timestamp.desc())
# Paginate
pagination = query.paginate(page=page, per_page=perpage, error_out=False)
rows = [log.to_dict() for log in pagination.items]
# Attach full names (hover tooltip on the SSO); best-effort.
fullnames = _resolve_full_names({r.get('username') for r in rows})
for r in rows:
r['userfullname'] = fullnames.get(str(r.get('username')))
return success_response(
rows,
meta={
'page': page,
'perpage': perpage,
'total': pagination.total,
'pages': pagination.pages
}
)
@auditlogs_bp.route('/entity/<entitytype>/<int:entityid>', methods=['GET'])
@jwt_required()
@require_permission('audit.view')
def get_entity_history(entitytype: str, entityid: int):
"""Get audit history for a specific entity."""
logs = AuditLog.query.filter_by(
entitytype=entitytype,
entityid=entityid
).order_by(AuditLog.timestamp.desc()).all()
return success_response([log.to_dict() for log in logs])
@auditlogs_bp.route('/stats', methods=['GET'])
@jwt_required()
@require_permission('audit.view')
def get_stats():
"""Get audit log statistics."""
from sqlalchemy import func
from datetime import datetime, timedelta, timezone
# Actions by type
actions = db_func_count_by(AuditLog.action)
# Entity types
entities = db_func_count_by(AuditLog.entitytype)
# Recent activity (last 7 days)
week_ago = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=7)
recent_count = AuditLog.query.filter(AuditLog.timestamp >= week_ago).count()
# Most active users (last 7 days)
from shopdb.extensions import db
active_users = db.session.query(
AuditLog.username,
func.count(AuditLog.auditlogid).label('count')
).filter(
AuditLog.timestamp >= week_ago,
AuditLog.username.isnot(None)
).group_by(AuditLog.username).order_by(func.count(AuditLog.auditlogid).desc()).limit(5).all()
return success_response({
'actions': actions,
'entities': entities,
'recentCount': recent_count,
'activeUsers': [{'username': u[0], 'count': u[1]} for u in active_users]
})
def db_func_count_by(column):
"""Helper to count grouped by a column."""
from sqlalchemy import func
from shopdb.extensions import db
results = db.session.query(
column,
func.count().label('count')
).group_by(column).all()
return {r[0]: r[1] for r in results}

258
shopdb/core/api/auth.py Normal file
View File

@@ -0,0 +1,258 @@
"""Authentication API endpoints."""
import time
from datetime import datetime, timedelta, timezone
from flask import Blueprint, request, current_app
from flask_jwt_extended import (
create_access_token,
create_refresh_token,
jwt_required,
get_jwt_identity,
current_user
)
from werkzeug.security import check_password_hash, generate_password_hash
from shopdb.extensions import db, cache
from shopdb.core.models import User
from shopdb.utils.responses import success_response, error_response, ErrorCodes
auth_bp = Blueprint('auth', __name__)
# Account lockout policy: after MAX_FAILED_LOGINS consecutive bad passwords,
# lock the account for LOCKOUT_MINUTES. Mitigates password brute-forcing.
MAX_FAILED_LOGINS = 5
LOCKOUT_MINUTES = 15
def _login_ip():
"""Caller IP for rate limiting, honoring the first X-Forwarded-For hop."""
forwarded = request.headers.get('X-Forwarded-For')
if forwarded:
return forwarded.split(',')[0].strip()
return request.remote_addr or 'unknown'
def _login_ratelimited():
"""Fixed-window per-IP login limiter. Returns True when the caller is over
budget for the current window.
Backed by the existing cache extension (no new dependency). Under the
default SimpleCache the counter is per-process, so with N gunicorn workers
the effective budget is N x AUTH_RATELIMIT_MAX. This is defense in depth
layered on top of the per-account lockout (see login()); a shared cache
backend (Redis/memcached) tightens it to a true global budget.
"""
if not current_app.config.get('AUTH_RATELIMIT_ENABLED', True):
return False
window = current_app.config.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300)
maxhits = current_app.config.get('AUTH_RATELIMIT_MAX', 30)
# Time bucket makes this a fixed window: the key rolls over at each window
# boundary, so a per-hit set() cannot turn it into a sliding window.
bucket = int(time.time() // window) if window > 0 else 0
key = f'loginratelimit:{_login_ip()}:{bucket}'
count = cache.get(key) or 0
if count >= maxhits:
return True
cache.set(key, count + 1, timeout=window)
return False
@auth_bp.route('/login', methods=['POST'])
def login():
"""
Authenticate user and return JWT tokens.
Request:
{
"username": "string",
"password": "string"
}
Response:
{
"data": {
"access_token": "...",
"refresh_token": "...",
"user": {...}
}
}
"""
if _login_ratelimited():
return error_response(
'RATE_LIMITED',
'Too many login attempts. Try again later.',
http_code=429
)
data = request.get_json()
if not data or not data.get('username') or not data.get('password'):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Username and password required'
)
user = User.query.filter_by(
username=data['username'],
isactive=True
).first()
# Reject a locked account before checking the password, so a lockout can't
# be probed and a valid password can't reset the clock mid-lockout.
if user and user.islocked:
return error_response(
ErrorCodes.FORBIDDEN,
'Account is locked. Try again later or contact an administrator.',
http_code=403
)
if not user or not check_password_hash(user.passwordhash, data['password']):
# Count the failure and lock the account once the threshold is hit.
# Only possible when the username matched a real account.
if user:
user.failedlogins = (user.failedlogins or 0) + 1
if user.failedlogins >= MAX_FAILED_LOGINS:
# Naive UTC to match the naive lockeduntil column comparisons.
user.lockeduntil = datetime.now(timezone.utc).replace(tzinfo=None) \
+ timedelta(minutes=LOCKOUT_MINUTES)
user.failedlogins = 0
db.session.commit()
return error_response(
ErrorCodes.UNAUTHORIZED,
'Invalid username or password',
http_code=401
)
# Create tokens (identity must be a string in Flask-JWT-Extended 4.x)
access_token = create_access_token(
identity=str(user.userid),
additional_claims={
'username': user.username,
'roles': [r.rolename for r in user.roles]
}
)
refresh_token = create_refresh_token(identity=str(user.userid))
# Update last login and clear any failed-login state
user.lastlogindate = db.func.now()
user.failedlogins = 0
user.lockeduntil = None
db.session.commit()
return success_response({
'access_token': access_token,
'refresh_token': refresh_token,
'token_type': 'Bearer',
'expires_in': 3600,
'user': {
'userid': user.userid,
'username': user.username,
'email': user.email,
'firstname': user.firstname,
'lastname': user.lastname,
'roles': [r.rolename for r in user.roles],
'mustchangepassword': bool(user.mustchangepassword)
}
})
@auth_bp.route('/refresh', methods=['POST'])
@jwt_required(refresh=True)
def refresh():
"""Refresh access token using refresh token."""
user_id = get_jwt_identity()
user = db.session.get(User, int(user_id))
if not user or not user.isactive:
return error_response(
ErrorCodes.UNAUTHORIZED,
'User not found or inactive',
http_code=401
)
access_token = create_access_token(
identity=str(user.userid),
additional_claims={
'username': user.username,
'roles': [r.rolename for r in user.roles]
}
)
return success_response({
'access_token': access_token,
'token_type': 'Bearer',
'expires_in': 3600
})
@auth_bp.route('/me', methods=['GET'])
@jwt_required()
def get_current_user():
"""Get current authenticated user info."""
return success_response({
'userid': current_user.userid,
'username': current_user.username,
'email': current_user.email,
'firstname': current_user.firstname,
'lastname': current_user.lastname,
'roles': [r.rolename for r in current_user.roles],
'permissions': current_user.getpermissions(),
'mustchangepassword': bool(current_user.mustchangepassword)
})
@auth_bp.route('/change-password', methods=['POST'])
@jwt_required()
def change_password():
"""Change the authenticated user's own password.
Request:
{ "current_password": "string", "new_password": "string" }
current_password is required for a normal self-service change. When the
account is flagged mustchangepassword (an admin set a temporary password),
the forced-change case accepts new_password alone. On success the flag is
cleared and any lockout/failed-login state is reset.
"""
data = request.get_json() or {}
new_password = data.get('new_password')
current_password = data.get('current_password')
if not new_password:
return error_response(
ErrorCodes.VALIDATION_ERROR, 'new_password is required')
if len(new_password) < 8:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'New password must be at least 8 characters')
user = current_user
# A normal change must prove knowledge of the current password. The forced
# first-login case (admin-set temp password) may skip it.
if not user.mustchangepassword:
if not current_password:
return error_response(
ErrorCodes.VALIDATION_ERROR, 'current_password is required')
if not check_password_hash(user.passwordhash, current_password):
return error_response(
ErrorCodes.UNAUTHORIZED,
'Current password is incorrect', http_code=401)
user.passwordhash = generate_password_hash(new_password)
user.mustchangepassword = False
user.failedlogins = 0
user.lockeduntil = None
db.session.commit()
return success_response(
{'mustchangepassword': False}, message='Password changed')
@auth_bp.route('/logout', methods=['POST'])
@jwt_required()
def logout():
"""Logout user (for frontend token cleanup)."""
# In a full implementation, you'd blacklist the token
return success_response(message='Successfully logged out')

View File

@@ -0,0 +1,156 @@
"""Business Units API endpoints - Full CRUD."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import BusinessUnit
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.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
businessunits_bp = Blueprint('businessunits', __name__)
@businessunits_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_businessunits():
"""List all business units."""
page, per_page = get_pagination_params(request)
query = BusinessUnit.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(BusinessUnit.isactive == True)
# Exact-match natural-key lookup for idempotent import (unit name).
if exactunit := request.args.get('businessunit'):
query = query.filter(BusinessUnit.businessunit == exactunit)
if search := request.args.get('search'):
query = query.filter(
db.or_(
BusinessUnit.businessunit.ilike(f'%{search}%'),
BusinessUnit.code.ilike(f'%{search}%')
)
)
query = query.order_by(BusinessUnit.businessunit)
items, total = paginate_query(query, page, per_page)
data = [bu.to_dict() for bu in items]
return paginated_response(data, page, per_page, total)
@businessunits_bp.route('/<int:bu_id>', methods=['GET'])
@jwt_required(optional=True)
def get_businessunit(bu_id: int):
"""Get a single business unit."""
bu = db.session.get(BusinessUnit, bu_id)
if not bu:
return error_response(
ErrorCodes.NOT_FOUND,
f'Business unit with ID {bu_id} not found',
http_code=404
)
data = bu.to_dict()
data['parent'] = bu.parent.to_dict() if bu.parent else None
data['children'] = [c.to_dict() for c in bu.children]
return success_response(data)
@businessunits_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_businessunit():
"""Create a new business unit."""
data = request.get_json()
if not data or not data.get('businessunit'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'businessunit is required')
if BusinessUnit.query.filter_by(businessunit=data['businessunit']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Business unit '{data['businessunit']}' already exists",
http_code=409
)
bu = BusinessUnit(
businessunit=data['businessunit'],
code=data.get('code'),
description=data.get('description'),
parentid=data.get('parentid')
)
db.session.add(bu)
apply_import_timestamps(bu, data)
db.session.commit()
return success_response(bu.to_dict(), message='Business unit created', http_code=201)
@businessunits_bp.route('/<int:bu_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_businessunit(bu_id: int):
"""Update a business unit."""
bu = db.session.get(BusinessUnit, bu_id)
if not bu:
return error_response(
ErrorCodes.NOT_FOUND,
f'Business unit with ID {bu_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if 'businessunit' in data and data['businessunit'] != bu.businessunit:
if BusinessUnit.query.filter_by(businessunit=data['businessunit']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Business unit '{data['businessunit']}' already exists",
http_code=409
)
for key in ['businessunit', 'code', 'description', 'parentid', 'isactive']:
if key in data:
setattr(bu, key, data[key])
apply_import_timestamps(bu, data)
db.session.commit()
return success_response(bu.to_dict(), message='Business unit updated')
@businessunits_bp.route('/<int:bu_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_businessunit(bu_id: int):
"""Delete (deactivate) a business unit."""
bu = db.session.get(BusinessUnit, bu_id)
if not bu:
return error_response(
ErrorCodes.NOT_FOUND,
f'Business unit with ID {bu_id} not found',
http_code=404
)
bu.isactive = False
db.session.commit()
return success_response(message='Business unit deleted')

View File

@@ -0,0 +1,486 @@
"""
PowerShell data-collection API endpoints.
Compatibility layer for the PowerShell scripts that report PC state. Uses an
API key (not JWT) for unattended scripts. Writes the asset/computer model
(ADR-001), not the retired Machine model.
"""
from datetime import datetime, timezone
from functools import wraps
from flask import Blueprint, request, current_app, g
from shopdb.extensions import db
from shopdb.core.models import Asset, Application
from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX
from shopdb.utils.responses import success_response, error_response, ErrorCodes
collector_bp = Blueprint('collector', __name__)
# A managed token scoped to this permission is a collector service token: it
# authorizes the collector ingest API and nothing else (ADR-006, machine
# identity - no require_permission on these routes).
COLLECTOR_SCOPE = 'collector.ingest'
def _computer_models():
"""Lazily import the computers plugin models.
The legacy /pc, /apps, /heartbeat, /bulk endpoints predate the generic
collector contract and are computers-specific. Importing the plugin lazily
(instead of at module load) keeps core importable when the computers plugin
is absent or disabled. Returns (Computer, ComputerInstalledApp) or None.
"""
try:
from plugins.computers.models import Computer, ComputerInstalledApp
return Computer, ComputerInstalledApp
except ImportError:
return None
def _token_is_collector(token):
"""True when this managed token is a collector service token: scoped for
collector.ingest AND its owner actually holds that permission (admins do)."""
from shopdb.core.models import User
scopelist = token.scopelist
if not scopelist or COLLECTOR_SCOPE not in scopelist:
return False
user = db.session.get(User, token.userid)
return (user is not None and user.isactive
and user.haspermission(COLLECTOR_SCOPE))
def _collector_managed_token(api_key):
"""Find the managed token backing this collector request, or None.
Two transports are accepted (GE-Enforce sends X-API-Key today; Bearer is
the standard PAT wire form):
X-API-Key: the before_request PAT shim ignores this header, so resolve the
secret here.
Bearer: the shim already resolved+swapped it (the raw Bearer secret is
gone by now), leaving g.apitokenid. Reuse that.
"""
from shopdb.utils.apitoken_auth import resolve_api_token
if api_key and api_key.startswith(TOKEN_SECRET_PREFIX):
resolved = resolve_api_token(api_key)
return resolved[0] if resolved else None
tokenid = getattr(g, 'apitokenid', None)
if tokenid is not None:
return db.session.get(ApiToken, tokenid)
return None
def _check_collector_auth(expected_key):
"""Authorize a collector request. Returns None when authorized, else the
error_response to return.
Accepts a collector-scoped managed token (Bearer or X-API-Key) OR the env
key via X-API-Key (legacy/bootstrap fallback). Env keys stay supported so
nothing breaks; a valid managed token works even with no env key set.
"""
from shopdb.utils.apitoken_auth import touch_apitoken_lastused
api_key = request.headers.get('X-API-Key')
token = _collector_managed_token(api_key)
if token is not None and _token_is_collector(token):
touch_apitoken_lastused(token)
return None
if expected_key and api_key == expected_key:
return None
# Fail-closed only on pure server misconfiguration: no env key AND the
# caller presented no managed token at all. Otherwise it is a bad credential.
presented_pat = token is not None or (
api_key is not None and api_key.startswith(TOKEN_SECRET_PREFIX))
if not expected_key and not presented_pat:
return error_response(ErrorCodes.INTERNAL_ERROR,
'Collector API key not configured', http_code=500)
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
http_code=401)
def require_api_key(f):
"""Require collector API-key OR collector-scoped managed-token auth."""
@wraps(f)
def decorated(*args, **kwargs):
# Header only. Querystring api_key was dropped so keys do not land in
# access logs / proxy history (breaking change, see COLLECTOR-INTEGRATION.md).
error = _check_collector_auth(current_app.config.get('COLLECTOR_API_KEY'))
if error is not None:
return error
return f(*args, **kwargs)
return decorated
def _find_pc(hostname):
"""Find a computer by hostname, falling back to its asset number.
Returns None if not found or if the computers plugin is unavailable;
callers already treat None as a 404.
"""
models = _computer_models()
if not models:
return None
Computer, _ = models
comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
if comp:
return comp
return (
Computer.query.join(Asset, Asset.assetid == Computer.assetid)
.filter(Asset.assetnumber.ilike(hostname))
.first()
)
def _parse_boot(value):
try:
return datetime.fromisoformat(value.replace('Z', '+00:00'))
except (ValueError, AttributeError):
return None
def _plugin_api_key(pluginname):
"""Per-plugin collector key, falling back to the shared key (ADR-006).
Looks up COLLECTOR_API_KEY_<PLUGINNAME> (uppercased) first so each
collector can carry its own credential, then COLLECTOR_API_KEY.
"""
per_plugin = current_app.config.get(
f'COLLECTOR_API_KEY_{pluginname.upper()}')
return per_plugin or current_app.config.get('COLLECTOR_API_KEY')
def _collector_plugins():
"""Map of pluginname -> (plugin, schema) for plugins accepting collector input."""
pm = current_app.extensions.get('plugin_manager')
result = {}
if not pm:
return result
for name, plugin in pm.get_all_plugins().items():
if not pm.registry.is_enabled(name):
continue
try:
schema = plugin.get_collector_schema()
except Exception:
# Fail loud in dev/test so a broken hook is visible; isolate the
# misbehaving plugin in prod and keep serving the healthy ones.
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise
current_app.logger.exception(
'Plugin %s get_collector_schema failed', name)
continue
if schema:
result[name] = (plugin, schema)
return result
@collector_bp.route('/<pluginname>', methods=['POST'])
def generic_collect(pluginname):
"""Generic collector ingest for any plugin (ADR-006).
Per-plugin API key auth, schema-driven validation of the identity field,
idempotent upsert via the plugin's apply_collector_payload. Returns the
ADR-006 response contract: status, action, assetid, identityvalue, warnings.
"""
from shopdb.core.models import AuditLog
plugins = _collector_plugins()
if pluginname not in plugins:
return error_response(
ErrorCodes.NOT_FOUND,
f'No collector registered for plugin {pluginname}',
http_code=404)
plugin, schema = plugins[pluginname]
# Per-plugin env key (fallback) OR a collector-scoped managed token. Header
# only (querystring fallback dropped, see require_api_key).
error = _check_collector_auth(_plugin_api_key(pluginname))
if error is not None:
return error
payload = request.get_json(silent=True)
if not payload or not isinstance(payload, dict):
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
identityfield = schema.get('identityfield')
identityvalue = (payload.get(identityfield) or '').strip() if identityfield else ''
if identityfield and not identityvalue:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'{identityfield} is required')
try:
outcome = plugin.apply_collector_payload(payload)
except NotImplementedError:
return error_response(
ErrorCodes.INTERNAL_ERROR,
f'Plugin {pluginname} does not implement apply_collector_payload',
http_code=500)
except ValueError as exc:
# ValueError is the plugin's controlled validation signal; its message
# is safe to return to the caller.
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
except Exception:
# Do not leak internals (stack detail, DB errors) to the caller; log it.
db.session.rollback()
current_app.logger.exception('Collector upsert failed for %s', pluginname)
return error_response(ErrorCodes.INTERNAL_ERROR,
'Internal error processing collector payload',
http_code=500)
action = outcome.get('action', 'noop')
AuditLog.log(
action if action in ('created', 'updated') else 'updated',
'Collector',
entityid=outcome.get('assetid'),
entityname=outcome.get('identityvalue', identityvalue),
details=f'collector:{pluginname} action={action}',
)
db.session.commit()
response = {
'status': 'ok',
'action': action,
'assetid': outcome.get('assetid'),
'identityvalue': outcome.get('identityvalue', identityvalue),
'warnings': outcome.get('warnings', []),
}
# Generic passthrough: a plugin may surface extra result fields (e.g. the
# computers plugin returns printerlinks). Known keys win over extras.
for key, value in (outcome.get('extra') or {}).items():
response.setdefault(key, value)
return success_response(response, message=f'{pluginname} collector {action}')
@collector_bp.route('/_schemas', methods=['GET'])
def collector_schemas():
"""List collector schemas for all enabled plugins (JWT-protected)."""
from flask_jwt_extended import verify_jwt_in_request
verify_jwt_in_request()
schemas = {
name: schema for name, (plugin, schema) in _collector_plugins().items()
}
return success_response({'schemas': schemas})
@collector_bp.route('/pc', methods=['POST'])
@require_api_key
def update_pc_info():
"""Update one PC from the collection script (matched by hostname)."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
hostname = data.get('hostname')
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required')
comp = _find_pc(hostname)
if not comp:
return error_response(ErrorCodes.NOT_FOUND,
f'PC with hostname {hostname} not found',
http_code=404)
comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None)
if data.get('lastboottime'):
boot = _parse_boot(data['lastboottime'])
if boot:
comp.lastboottime = boot
if data.get('currentuser'):
comp.loggedinuser = data['currentuser']
if data.get('serialnumber') and comp.asset:
comp.asset.serialnumber = data['serialnumber']
db.session.commit()
return success_response({
'computerid': comp.computerid,
'hostname': comp.hostname,
'updated': True
}, message='PC info updated')
@collector_bp.route('/apps', methods=['POST'])
@require_api_key
def update_installed_apps():
"""Update installed applications for a PC (matched by hostname)."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
hostname = data.get('hostname')
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required')
apps = data.get('apps', [])
if not apps:
return error_response(ErrorCodes.VALIDATION_ERROR, 'apps list is required')
comp = _find_pc(hostname)
if not comp:
return error_response(ErrorCodes.NOT_FOUND,
f'PC with hostname {hostname} not found',
http_code=404)
# comp existing implies the computers plugin is loaded.
_, ComputerInstalledApp = _computer_models()
updated_count = created_count = skipped_count = 0
for app_data in apps:
app_name = app_data.get('appname')
if not app_name:
skipped_count += 1
continue
app = Application.query.filter(Application.appname.ilike(app_name)).first()
if not app:
# Only track applications we manage
skipped_count += 1
continue
version = app_data.get('version')
installed = ComputerInstalledApp.query.filter_by(
computerid=comp.computerid, appid=app.appid).first()
if installed:
changed = False
if version and installed.installedversion != version:
installed.installedversion = version
changed = True
if not installed.isactive:
installed.isactive = True
changed = True
if changed:
updated_count += 1
else:
db.session.add(ComputerInstalledApp(
computerid=comp.computerid,
appid=app.appid,
installedversion=version,
))
created_count += 1
db.session.commit()
return success_response({
'hostname': hostname,
'computerid': comp.computerid,
'created': created_count,
'updated': updated_count,
'skipped': skipped_count
}, message='Installed apps updated')
@collector_bp.route('/heartbeat', methods=['POST'])
@require_api_key
def pc_heartbeat():
"""Record PC online status / heartbeat (single or batch)."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
hostnames = data.get('hostnames', [])
if not hostnames and data.get('hostname'):
hostnames = [data['hostname']]
if not hostnames:
return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname or hostnames required')
updated = 0
not_found = []
now = datetime.now(timezone.utc).replace(tzinfo=None)
for hostname in hostnames:
comp = _find_pc(hostname)
if comp:
comp.lastreporteddate = now
updated += 1
else:
not_found.append(hostname)
db.session.commit()
return success_response({
'updated': updated,
'notfound': not_found,
'timestamp': now.isoformat()
}, message=f'{updated} PC(s) heartbeat recorded')
@collector_bp.route('/bulk', methods=['POST'])
@require_api_key
def bulk_update():
"""Bulk update multiple PCs at once."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
pcs = data.get('pcs', [])
if not pcs:
return error_response(ErrorCodes.VALIDATION_ERROR, 'pcs list is required')
updated = 0
not_found = []
errors = []
now = datetime.now(timezone.utc).replace(tzinfo=None)
for pc_data in pcs:
hostname = pc_data.get('hostname')
if not hostname:
continue
comp = _find_pc(hostname)
if not comp:
not_found.append(hostname)
continue
try:
comp.lastreporteddate = now
if pc_data.get('currentuser'):
comp.loggedinuser = pc_data['currentuser']
if pc_data.get('lastboottime'):
boot = _parse_boot(pc_data['lastboottime'])
if boot:
comp.lastboottime = boot
updated += 1
except Exception:
# Keep the hostname so the caller knows which PC failed, but do not
# leak the exception detail; log it server-side.
current_app.logger.exception('Bulk update failed for %s', hostname)
errors.append({'hostname': hostname, 'error': 'processing failed'})
db.session.commit()
return success_response({
'updated': updated,
'notfound': not_found,
'errors': errors,
'timestamp': now.isoformat()
}, message=f'{updated} PC(s) updated')
@collector_bp.route('/status', methods=['GET'])
@require_api_key
def collector_status():
"""Check collector API status."""
return success_response({
'status': 'ok',
'timestamp': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'endpoints': [
'POST /api/collector/<plugin>',
'GET /api/collector/_schemas',
'POST /api/collector/pc',
'POST /api/collector/apps',
'POST /api/collector/heartbeat',
'POST /api/collector/bulk',
'GET /api/collector/status'
]
})

View File

@@ -0,0 +1,218 @@
"""Custom fields API - definitions per asset type + per-asset values."""
import json
import re
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import CustomField, CustomFieldValue, Asset, AssetType
from shopdb.core.models.customfield import CUSTOM_FIELD_DATATYPES
from shopdb.utils.responses import (
success_response,
error_response,
ErrorCodes
)
from shopdb.utils.authz import require_role
customfields_bp = Blueprint('customfields', __name__)
def _slugify_key(label):
"""Build a stable machine key from a label: lowercase, alnum only."""
key = re.sub(r'[^a-z0-9]', '', (label or '').lower())
return key[:50] or 'field'
def _normalize_options(raw):
"""Return a JSON string of a clean list of option strings, or None."""
if not raw:
return None
if isinstance(raw, str):
# Accept newline- or comma-separated text too.
parts = re.split(r'[\n,]', raw)
elif isinstance(raw, (list, tuple)):
parts = raw
else:
return None
cleaned = [str(p).strip() for p in parts if str(p).strip()]
return json.dumps(cleaned) if cleaned else None
# =============================================================================
# Field definitions
# =============================================================================
@customfields_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_fields():
"""List custom-field definitions. Filter by ?assettypeid=. ?active=false
includes inactive ones."""
query = CustomField.query
assettypeid = request.args.get('assettypeid', type=int)
if assettypeid:
query = query.filter_by(assettypeid=assettypeid)
if request.args.get('active', 'true').lower() != 'false':
query = query.filter_by(isactive=True)
fields = query.order_by(CustomField.sortorder, CustomField.fieldid).all()
return success_response([f.to_dict() for f in fields])
@customfields_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_field():
data = request.get_json() or {}
assettypeid = data.get('assettypeid')
label = (data.get('label') or '').strip()
if not assettypeid or not label:
return error_response(ErrorCodes.VALIDATION_ERROR, 'assettypeid and label are required')
if not db.session.get(AssetType, assettypeid):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown assettypeid')
datatype = data.get('datatype') or 'text'
if datatype not in CUSTOM_FIELD_DATATYPES:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'datatype must be one of {", ".join(CUSTOM_FIELD_DATATYPES)}')
fieldkey = (data.get('fieldkey') or '').strip() or _slugify_key(label)
# Reactivate a same-key field that was soft-deleted, instead of colliding.
existing = CustomField.query.filter_by(assettypeid=assettypeid, fieldkey=fieldkey).first()
if existing:
if not existing.isactive:
existing.isactive = True
existing.label = label
existing.datatype = datatype
existing.options = _normalize_options(data.get('options'))
existing.showondetail = bool(data.get('showondetail', True))
existing.showonform = bool(data.get('showonform', True))
existing.searchable = bool(data.get('searchable', False))
existing.sortorder = data.get('sortorder', 0)
db.session.commit()
return success_response(existing.to_dict(), message='Reactivated existing field')
return error_response(ErrorCodes.CONFLICT,
f'A field with key "{fieldkey}" already exists for this asset type')
field = CustomField(
assettypeid=assettypeid,
fieldkey=fieldkey,
label=label,
datatype=datatype,
options=_normalize_options(data.get('options')),
showondetail=bool(data.get('showondetail', True)),
showonform=bool(data.get('showonform', True)),
searchable=bool(data.get('searchable', False)),
sortorder=data.get('sortorder', 0),
)
db.session.add(field)
db.session.commit()
return success_response(field.to_dict(), message='Custom field created', http_code=201)
@customfields_bp.route('/<int:fieldid>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_field(fieldid):
field = db.session.get(CustomField, fieldid)
if not field:
return error_response(ErrorCodes.NOT_FOUND, 'Custom field not found', http_code=404)
data = request.get_json() or {}
if 'label' in data:
field.label = (data['label'] or '').strip() or field.label
if 'datatype' in data:
if data['datatype'] not in CUSTOM_FIELD_DATATYPES:
return error_response(ErrorCodes.VALIDATION_ERROR, 'invalid datatype')
field.datatype = data['datatype']
if 'options' in data:
field.options = _normalize_options(data['options'])
for key in ('showondetail', 'showonform', 'isactive', 'searchable'):
if key in data:
setattr(field, key, bool(data[key]))
if 'sortorder' in data:
field.sortorder = data['sortorder']
db.session.commit()
return success_response(field.to_dict(), message='Custom field updated')
@customfields_bp.route('/<int:fieldid>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_field(fieldid):
field = db.session.get(CustomField, fieldid)
if not field:
return error_response(ErrorCodes.NOT_FOUND, 'Custom field not found', http_code=404)
# Drop the field and any stored values for it.
CustomFieldValue.query.filter_by(fieldid=fieldid).delete()
db.session.delete(field)
db.session.commit()
return success_response(message='Custom field deleted')
# =============================================================================
# Per-asset values
# =============================================================================
def _fields_with_values(asset):
"""Return active field defs for this asset's type, each with the asset's
stored value merged in."""
fields = (CustomField.query
.filter_by(assettypeid=asset.assettypeid, isactive=True)
.order_by(CustomField.sortorder, CustomField.fieldid)
.all())
values = {v.fieldid: v.value for v in
CustomFieldValue.query.filter_by(assetid=asset.assetid).all()}
result = []
for f in fields:
item = f.to_dict()
item['value'] = values.get(f.fieldid)
result.append(item)
return result
@customfields_bp.route('/asset/<int:assetid>', methods=['GET'])
@jwt_required(optional=True)
def get_asset_fields(assetid):
"""Active custom fields for an asset's type, merged with its values."""
asset = db.session.get(Asset, assetid)
if not asset:
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
return success_response(_fields_with_values(asset))
@customfields_bp.route('/asset/<int:assetid>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def save_asset_fields(assetid):
"""Upsert values for an asset. Body: {values: {fieldid: value, ...}}."""
asset = db.session.get(Asset, assetid)
if not asset:
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
data = request.get_json() or {}
values = data.get('values') or {}
# Only accept fields that belong to this asset's type.
valid_ids = {f.fieldid for f in
CustomField.query.filter_by(assettypeid=asset.assettypeid).all()}
for raw_fieldid, raw_value in values.items():
try:
fieldid = int(raw_fieldid)
except (ValueError, TypeError):
continue
if fieldid not in valid_ids:
continue
value = '' if raw_value is None else str(raw_value)
row = CustomFieldValue.query.filter_by(fieldid=fieldid, assetid=assetid).first()
if value == '':
# Empty clears the stored value.
if row:
db.session.delete(row)
continue
if row:
row.value = value
else:
db.session.add(CustomFieldValue(fieldid=fieldid, assetid=assetid, value=value))
db.session.commit()
return success_response(_fields_with_values(asset), message='Custom fields saved')

View File

@@ -0,0 +1,216 @@
"""Dashboard API endpoints."""
from flask import Blueprint, current_app
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, AssetStatus
from shopdb.utils.responses import success_response
dashboard_bp = Blueprint('dashboard', __name__)
# Map asset type name -> dashboard category label
_TYPE_CATEGORY = {
'machine': 'Machine',
'computer': 'PC',
'printer': 'Printer',
'network_device': 'Network',
'measuring_tool': 'Measuring Tool',
}
def _count_by_type(assettype):
query = db.session.query(Asset).join(AssetType).filter(
Asset.isactive == True,
AssetType.assettype == assettype
)
# Dualpath single-machine collapse: subtract the hidden secondary bays so a
# dual-bay machine counts once. Only machines are ever Dualpath-paired, but
# the AssetType filter above keeps this correct for any type. Default on.
if assettype == 'machine':
from shopdb.core.services.dualpath import (
resolve_dualpath_pairs, dualpath_single_machine_enabled)
if dualpath_single_machine_enabled():
secondaryassetids = resolve_dualpath_pairs().secondaryassetids
if secondaryassetids:
query = query.filter(Asset.assetid.notin_(secondaryassetids))
return query.count()
@dashboard_bp.route('/summary', methods=['GET'])
@dashboard_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def get_dashboard():
"""Get dashboard summary data (asset-based)."""
machine_count = _count_by_type('machine')
pc_count = _count_by_type('computer')
network_count = _count_by_type('network_device')
printer_count = _count_by_type('printer')
measuringtool_count = _count_by_type('measuring_tool')
total = machine_count + pc_count + network_count + printer_count + measuringtool_count
# Count by status
status_counts = db.session.query(
AssetStatus.status,
db.func.count(Asset.assetid)
).outerjoin(
Asset,
db.and_(Asset.statusid == AssetStatus.statusid, Asset.isactive == True)
).group_by(AssetStatus.status).all()
status_dict = {status: count for status, count in status_counts}
# Recent assets
recent = Asset.query.filter_by(isactive=True).order_by(
Asset.createddate.desc()
).limit(10).all()
return success_response({
# Fields expected by frontend (totalmachines now means machines,
# totalassets is the grand total - renamed with the machines plugin)
'totalassets': total,
'totalmachines': machine_count,
'totalpc': pc_count,
'totalnetwork': network_count,
'totalprinter': printer_count,
'totalmeasuringtool': measuringtool_count,
'activeassets': status_dict.get('In Use', 0),
'inrepair': status_dict.get('In Repair', 0),
# Structured data
'counts': {
'machines': machine_count,
'pcs': pc_count,
'networkdevices': network_count,
'printers': printer_count,
'measuringtools': measuringtool_count,
'total': total
},
'bystatus': status_dict,
'recent': [
{
'assetid': a.assetid,
'assetnumber': a.assetnumber,
'assettype': a.assettype.assettype if a.assettype else None,
'createddate': a.createddate.isoformat() + 'Z' if a.createddate else None
}
for a in recent
]
})
@dashboard_bp.route('/stats', methods=['GET'])
@jwt_required(optional=True)
def get_stats():
"""Get detailed statistics by asset type."""
type_counts = db.session.query(
AssetType.assettype,
db.func.count(Asset.assetid)
).outerjoin(
Asset,
db.and_(Asset.assettypeid == AssetType.assettypeid, Asset.isactive == True)
).group_by(AssetType.assettypeid).all()
return success_response({
'bytype': [
{
'type': t,
'category': _TYPE_CATEGORY.get(t, t),
'count': count
}
for t, count in type_counts
]
})
@dashboard_bp.route('/navigation', methods=['GET'])
def get_navigation():
"""Get navigation items from all loaded plugins."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
return success_response([])
all_items = []
# Core navigation items (always present)
all_items.extend([
{'name': 'Dashboard', 'icon': 'layout-dashboard', 'route': '/', 'position': 0},
{'name': 'Map', 'icon': 'map', 'route': '/map', 'position': 4},
])
# Collect navigation items from enabled plugins. Disabling persists to the
# registry immediately, so a disabled plugin drops out of the menu right
# away (its routes stay registered until the next restart - Flask cannot
# unregister a blueprint at runtime).
for name, plugin in pm.get_all_plugins().items():
if not pm.registry.is_enabled(name):
continue
try:
items = plugin.get_navigation_items()
for item in items:
item['plugin'] = name
all_items.extend(items)
except Exception:
# Fail loud in dev/test; isolate a broken plugin in prod.
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise
current_app.logger.exception(
'Plugin %s get_navigation_items failed', name)
# Add core information section items
all_items.extend([
{'name': 'Applications', 'icon': 'app-window', 'route': '/applications', 'position': 30, 'section': 'information'},
{'name': 'Reports', 'icon': 'bar-chart-3', 'route': '/reports', 'position': 40, 'section': 'information'},
])
# Sort by position
all_items.sort(key=lambda x: x.get('position', 99))
return success_response(all_items)
@dashboard_bp.route('/widgets', methods=['GET'])
@jwt_required(optional=True)
def get_widgets():
"""Aggregate dashboard widget definitions from all enabled plugins.
Consumer for the BasePlugin.get_dashboard_widgets hook. Skips disabled
plugins, isolates a broken plugin in prod (re-raises in dev/test), and
returns the merged list sorted by position.
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
return success_response([])
widgets = []
for name, plugin in pm.get_all_plugins().items():
if not pm.registry.is_enabled(name):
continue
try:
for widget in plugin.get_dashboard_widgets() or []:
widget['plugin'] = name
widgets.append(widget)
except Exception:
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise
current_app.logger.exception(
'Plugin %s get_dashboard_widgets failed', name)
widgets.sort(key=lambda w: w.get('position', 99))
return success_response(widgets)
@dashboard_bp.route('/health', methods=['GET'])
def health_check():
"""Health check endpoint (no auth required)."""
from shopdb import __version__
try:
db.session.execute(db.text('SELECT 1'))
db_status = 'healthy'
except Exception as e:
db_status = f'unhealthy: {str(e)}'
return success_response({
'status': 'ok' if db_status == 'healthy' else 'degraded',
'database': db_status,
'version': __version__
})

View File

@@ -0,0 +1,131 @@
"""Dashboard defaults API: visitor-IP -> business-unit mapping.
CRUD for the mappings plus a resolve endpoint a kiosk/lobby dashboard calls to
auto-select its business unit from the display PC's IP.
"""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import DashboardDefault, BusinessUnit, AuditLog
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
dashboarddefaults_bp = Blueprint('dashboarddefaults', __name__)
def _request_ip():
"""Caller IP, honoring a single proxy hop via X-Forwarded-For."""
forwarded = request.headers.get('X-Forwarded-For')
if forwarded:
return forwarded.split(',')[0].strip()
return request.remote_addr
def _serialize(default):
data = default.to_dict()
data['businessunit'] = default.businessunit.businessunit \
if default.businessunit else None
return data
@dashboarddefaults_bp.route('/visitor-location', methods=['GET'])
def visitor_location():
"""Resolve the business unit for the calling display by its IP.
Unauthenticated: kiosks/lobby displays hit this. Returns the mapped
business unit, or a null businessunitid when the IP is not mapped.
"""
ipaddress = request.args.get('ipaddress') or _request_ip()
default = DashboardDefault.query.filter_by(
ipaddress=ipaddress, isactive=True).first()
if not default:
return success_response({
'ipaddress': ipaddress,
'businessunitid': None,
'businessunit': None,
})
return success_response({
'ipaddress': ipaddress,
'businessunitid': default.businessunitid,
'businessunit': default.businessunit.businessunit
if default.businessunit else None,
})
@dashboarddefaults_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_defaults():
"""List all visitor-IP -> business-unit mappings."""
defaults = DashboardDefault.query.filter_by(isactive=True).order_by(
DashboardDefault.ipaddress).all()
return success_response([_serialize(d) for d in defaults])
@dashboarddefaults_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_default():
"""Create a visitor-IP -> business-unit mapping."""
data = request.get_json() or {}
ipaddress = (data.get('ipaddress') or '').strip()
businessunitid = data.get('businessunitid')
if not ipaddress:
return error_response(ErrorCodes.VALIDATION_ERROR, 'ipaddress is required')
if not businessunitid:
return error_response(ErrorCodes.VALIDATION_ERROR, 'businessunitid is required')
if not db.session.get(BusinessUnit, businessunitid):
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
http_code=404)
if DashboardDefault.query.filter_by(ipaddress=ipaddress, isactive=True).first():
return error_response(ErrorCodes.CONFLICT,
f"IP {ipaddress} is already mapped", http_code=409)
default = DashboardDefault(ipaddress=ipaddress, businessunitid=businessunitid,
description=data.get('description'))
db.session.add(default)
db.session.flush()
AuditLog.log('created', 'DashboardDefault', entityid=default.dashboarddefaultid,
entityname=ipaddress)
db.session.commit()
return success_response(_serialize(default), message='Mapping created',
http_code=201)
@dashboarddefaults_bp.route('/<int:default_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_default(default_id):
"""Update a mapping."""
default = db.session.get(DashboardDefault, default_id)
if not default:
return error_response(ErrorCodes.NOT_FOUND, 'Mapping not found', http_code=404)
data = request.get_json() or {}
if 'businessunitid' in data:
if not db.session.get(BusinessUnit, data['businessunitid']):
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
http_code=404)
default.businessunitid = data['businessunitid']
if 'ipaddress' in data and data['ipaddress']:
default.ipaddress = data['ipaddress'].strip()
if 'description' in data:
default.description = data['description']
db.session.commit()
return success_response(_serialize(default), message='Mapping updated')
@dashboarddefaults_bp.route('/<int:default_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_default(default_id):
"""Delete (deactivate) a mapping."""
default = db.session.get(DashboardDefault, default_id)
if not default:
return error_response(ErrorCodes.NOT_FOUND, 'Mapping not found', http_code=404)
default.isactive = False
db.session.commit()
return success_response(message='Mapping deleted')

View File

@@ -0,0 +1,241 @@
"""Locations API endpoints - Full CRUD."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Location, LocationType
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.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
locations_bp = Blueprint('locations', __name__)
def _loc_type_dict(t):
return {
'locationtypeid': t.locationtypeid,
'locationtype': t.locationtype,
'description': t.description,
'color': t.color,
'isactive': t.isactive,
}
@locations_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
def list_location_types():
"""List location types. ?active=false includes inactive ones."""
query = LocationType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter_by(isactive=True)
types = query.order_by(LocationType.locationtype).all()
return success_response([_loc_type_dict(t) for t in types])
@locations_bp.route('/types', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_location_type():
data = request.get_json() or {}
if not data.get('locationtype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'locationtype is required')
existing = LocationType.query.filter_by(locationtype=data['locationtype']).first()
if existing:
if not existing.isactive:
existing.isactive = True
for key in ('description', 'color'):
if data.get(key) is not None:
setattr(existing, key, data[key])
db.session.commit()
return success_response(_loc_type_dict(existing), message='Reactivated existing type')
return error_response(ErrorCodes.CONFLICT,
f"Location type '{data['locationtype']}' already exists", http_code=409)
t = LocationType(locationtype=data['locationtype'],
description=data.get('description'), color=data.get('color'))
db.session.add(t)
db.session.commit()
return success_response(_loc_type_dict(t), message='Location type created', http_code=201)
@locations_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_location_type(type_id):
t = db.session.get(LocationType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Location type not found', http_code=404)
data = request.get_json() or {}
if 'locationtype' in data and data['locationtype'] != t.locationtype:
if LocationType.query.filter_by(locationtype=data['locationtype']).first():
return error_response(ErrorCodes.CONFLICT,
f"Location type '{data['locationtype']}' already exists", http_code=409)
for key in ('locationtype', 'description', 'color', 'isactive'):
if key in data:
setattr(t, key, data[key])
db.session.commit()
return success_response(_loc_type_dict(t), message='Location type updated')
@locations_bp.route('/types/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_location_type(type_id):
t = db.session.get(LocationType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Location type not found', http_code=404)
inuse = Location.query.filter_by(locationtypeid=type_id).count()
if inuse:
return error_response(ErrorCodes.CONFLICT,
f"Cannot delete: {inuse} location(s) still use this type", http_code=409)
db.session.delete(t)
db.session.commit()
return success_response(message='Location type deleted')
@locations_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_locations():
"""List all locations."""
page, per_page = get_pagination_params(request)
query = Location.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Location.isactive == True)
# Exact-match natural-key lookup for idempotent import (location name).
if exactname := request.args.get('locationname'):
query = query.filter(Location.locationname == exactname)
if search := request.args.get('search'):
query = query.filter(
db.or_(
Location.locationname.ilike(f'%{search}%'),
Location.building.ilike(f'%{search}%')
)
)
query = query.order_by(Location.locationname)
items, total = paginate_query(query, page, per_page)
data = [loc.to_dict() for loc in items]
return paginated_response(data, page, per_page, total)
@locations_bp.route('/<int:location_id>', methods=['GET'])
@jwt_required(optional=True)
def get_location(location_id: int):
"""Get a single location."""
loc = db.session.get(Location, location_id)
if not loc:
return error_response(
ErrorCodes.NOT_FOUND,
f'Location with ID {location_id} not found',
http_code=404
)
return success_response(loc.to_dict())
@locations_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_location():
"""Create a new location."""
data = request.get_json()
if not data or not data.get('locationname'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'locationname is required')
if Location.query.filter_by(locationname=data['locationname']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Location '{data['locationname']}' already exists",
http_code=409
)
loc = Location(
locationname=data['locationname'],
building=data.get('building'),
floor=data.get('floor'),
room=data.get('room'),
description=data.get('description'),
locationtypeid=data.get('locationtypeid'),
parentlocationid=data.get('parentlocationid'),
mapimage=data.get('mapimage'),
mapwidth=data.get('mapwidth'),
mapheight=data.get('mapheight')
)
db.session.add(loc)
apply_import_timestamps(loc, data)
db.session.commit()
return success_response(loc.to_dict(), message='Location created', http_code=201)
@locations_bp.route('/<int:location_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_location(location_id: int):
"""Update a location."""
loc = db.session.get(Location, location_id)
if not loc:
return error_response(
ErrorCodes.NOT_FOUND,
f'Location with ID {location_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if 'locationname' in data and data['locationname'] != loc.locationname:
if Location.query.filter_by(locationname=data['locationname']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Location '{data['locationname']}' already exists",
http_code=409
)
for key in ['locationname', 'building', 'floor', 'room', 'description',
'locationtypeid', 'parentlocationid', 'mapimage', 'mapwidth',
'mapheight', 'isactive']:
if key in data:
setattr(loc, key, data[key])
apply_import_timestamps(loc, data)
db.session.commit()
return success_response(loc.to_dict(), message='Location updated')
@locations_bp.route('/<int:location_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_location(location_id: int):
"""Delete (deactivate) a location."""
loc = db.session.get(Location, location_id)
if not loc:
return error_response(
ErrorCodes.NOT_FOUND,
f'Location with ID {location_id} not found',
http_code=404
)
loc.isactive = False
db.session.commit()
return success_response(message='Location deleted')

262
shopdb/core/api/models.py Normal file
View File

@@ -0,0 +1,262 @@
"""Models (vendor model catalog) API endpoints - Full CRUD."""
import glob
import os
from flask import Blueprint, request, current_app, send_from_directory
from flask_jwt_extended import jwt_required
from werkzeug.utils import secure_filename
from shopdb.extensions import db
from shopdb.core.models import Model
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.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
models_bp = Blueprint('models', __name__)
# Uploaded model photos live in the instance dir and are served publicly
# (asset detail pages read the model image without auth). Same image set the
# map/branding uploads accept.
MODEL_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
# URL prefix an uploaded image resolves to. imageurl values with this prefix
# are our own files under the instance dir; anything else (external URLs or the
# shipped /images/models/* assets) is left on disk untouched.
MODEL_IMAGE_URL_PREFIX = '/api/models/image/'
def _modelimage_dir():
return os.path.join(current_app.instance_path, 'modelimages')
@models_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_models():
"""List all vendor catalog models."""
page, per_page = get_pagination_params(request)
query = Model.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Model.isactive == True)
if vendor_id := request.args.get('vendor', type=int):
query = query.filter(Model.vendorid == vendor_id)
if modeltype_id := request.args.get('modeltype', type=int):
query = query.filter(Model.modeltypeid == modeltype_id)
# Exact-match natural-key lookup for idempotent import. Natural key is
# modelnumber + vendor; pair this with ?vendor=<id> to disambiguate.
if exactmodelnumber := request.args.get('modelnumber'):
query = query.filter(Model.modelnumber == exactmodelnumber)
if search := request.args.get('search'):
query = query.filter(Model.modelnumber.ilike(f'%{search}%'))
query = query.order_by(Model.modelnumber)
items, total = paginate_query(query, page, per_page)
data = []
for m in items:
d = m.to_dict()
d['vendor'] = m.vendor.vendor if m.vendor else None
d['modeltype'] = m.modeltype.modeltype if m.modeltype else None
data.append(d)
return paginated_response(data, page, per_page, total)
@models_bp.route('/<int:model_id>', methods=['GET'])
@jwt_required(optional=True)
def get_model(model_id: int):
"""Get a single model."""
m = db.session.get(Model, model_id)
if not m:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model with ID {model_id} not found',
http_code=404
)
data = m.to_dict()
data['vendor'] = m.vendor.to_dict() if m.vendor else None
data['modeltype'] = m.modeltype.to_dict() if m.modeltype else None
return success_response(data)
@models_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_model():
"""Create a new model."""
data = request.get_json()
if not data or not data.get('modelnumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'modelnumber is required')
# Check duplicate
existing = Model.query.filter_by(
modelnumber=data['modelnumber'],
vendorid=data.get('vendorid')
).first()
if existing:
return error_response(
ErrorCodes.CONFLICT,
f"Model '{data['modelnumber']}' already exists for this vendor",
http_code=409
)
m = Model(
modelnumber=data['modelnumber'],
vendorid=data.get('vendorid'),
modeltypeid=data.get('modeltypeid'),
description=data.get('description'),
imageurl=data.get('imageurl'),
documentationurl=data.get('documentationurl'),
notes=data.get('notes')
)
db.session.add(m)
apply_import_timestamps(m, data)
db.session.commit()
return success_response(m.to_dict(), message='Model created', http_code=201)
@models_bp.route('/<int:model_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_model(model_id: int):
"""Update a model."""
m = db.session.get(Model, model_id)
if not m:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model with ID {model_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
for key in ['modelnumber', 'vendorid', 'modeltypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']:
if key in data:
setattr(m, key, data[key])
apply_import_timestamps(m, data)
db.session.commit()
return success_response(m.to_dict(), message='Model updated')
@models_bp.route('/<int:model_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_model(model_id: int):
"""Delete (deactivate) a model."""
m = db.session.get(Model, model_id)
if not m:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model with ID {model_id} not found',
http_code=404
)
m.isactive = False
db.session.commit()
return success_response(message='Model deleted')
@models_bp.route('/<int:model_id>/image', methods=['POST'])
@jwt_required()
@require_role('admin')
def upload_model_image(model_id: int):
"""Upload (or replace) the photo for a model.
multipart/form-data: file=<image>. Saves to the instance modelimages dir as
model-<id><ext> (one image per model) and points model.imageurl at the
served URL. Re-upload replaces the old file even when the extension changes.
"""
m = db.session.get(Model, model_id)
if not m:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model with ID {model_id} not found',
http_code=404
)
upload = request.files.get('file')
if not upload or not upload.filename:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
ext = os.path.splitext(upload.filename)[1].lower()
if ext not in MODEL_IMAGE_EXTENSIONS:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unsupported image type {ext}')
imagedir = _modelimage_dir()
os.makedirs(imagedir, exist_ok=True)
# Wipe any prior model-<id>.* file so a new extension does not orphan the old
# one (one image per model).
for old in glob.glob(os.path.join(imagedir, secure_filename(f'model-{model_id}') + '.*')):
os.remove(old)
filename = secure_filename(f'model-{model_id}{ext}')
upload.save(os.path.join(imagedir, filename))
m.imageurl = f'{MODEL_IMAGE_URL_PREFIX}{filename}'
db.session.commit()
return success_response(m.to_dict(), message='Model image uploaded')
@models_bp.route('/image/<path:filename>', methods=['GET'])
def serve_model_image(filename):
"""Serve an uploaded model image (public - asset detail pages read it)."""
return send_from_directory(_modelimage_dir(), filename)
@models_bp.route('/<int:model_id>/image', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_model_image(model_id: int):
"""Clear a model image and delete the uploaded file if we own it.
External URLs and the shipped /images/models/* assets are never touched on
disk - only the imageurl field is cleared.
"""
m = db.session.get(Model, model_id)
if not m:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model with ID {model_id} not found',
http_code=404
)
url = m.imageurl or ''
if url.startswith(MODEL_IMAGE_URL_PREFIX):
# secure_filename strips any traversal; the file lives in our dir only.
filename = secure_filename(url[len(MODEL_IMAGE_URL_PREFIX):])
path = os.path.join(_modelimage_dir(), filename)
if os.path.exists(path):
os.remove(path)
m.imageurl = None
db.session.commit()
return success_response(m.to_dict(), message='Model image removed')

View File

@@ -0,0 +1,164 @@
"""Model Types API endpoints - Full CRUD.
Types the vendor MODELS catalog (models.modeltypeid). Renamed from
machinetypes; the machines plugin now owns the "machinetypes" name.
"""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import ModelType
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.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
modeltypes_bp = Blueprint('modeltypes', __name__)
@modeltypes_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_modeltypes():
"""List all model types with optional filtering."""
page, per_page = get_pagination_params(request)
query = ModelType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(ModelType.isactive == True)
if category := request.args.get('category'):
query = query.filter(ModelType.category == category)
# Exact-match natural-key lookup for idempotent import (type name).
if exactmodeltype := request.args.get('modeltype'):
query = query.filter(ModelType.modeltype == exactmodeltype)
if search := request.args.get('search'):
query = query.filter(ModelType.modeltype.ilike(f'%{search}%'))
query = query.order_by(ModelType.modeltype)
items, total = paginate_query(query, page, per_page)
data = [mt.to_dict() for mt in items]
return paginated_response(data, page, per_page, total)
@modeltypes_bp.route('/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
def get_modeltype(type_id: int):
"""Get a single model type."""
mt = db.session.get(ModelType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model type with ID {type_id} not found',
http_code=404
)
return success_response(mt.to_dict())
@modeltypes_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_modeltype():
"""Create a new model type."""
data = request.get_json()
if not data or not data.get('modeltype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'modeltype is required')
if ModelType.query.filter_by(modeltype=data['modeltype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Model type '{data['modeltype']}' already exists",
http_code=409
)
mt = ModelType(
modeltype=data['modeltype'],
category=data.get('category', 'Equipment'),
description=data.get('description'),
icon=data.get('icon')
)
db.session.add(mt)
apply_import_timestamps(mt, data)
db.session.commit()
return success_response(mt.to_dict(), message='Model type created', http_code=201)
@modeltypes_bp.route('/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_modeltype(type_id: int):
"""Update a model type."""
mt = db.session.get(ModelType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model type with ID {type_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Check duplicate name
if 'modeltype' in data and data['modeltype'] != mt.modeltype:
if ModelType.query.filter_by(modeltype=data['modeltype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Model type '{data['modeltype']}' already exists",
http_code=409
)
for key in ['modeltype', 'category', 'description', 'icon', 'isactive']:
if key in data:
setattr(mt, key, data[key])
apply_import_timestamps(mt, data)
db.session.commit()
return success_response(mt.to_dict(), message='Model type updated')
@modeltypes_bp.route('/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_modeltype(type_id: int):
"""Delete (deactivate) a model type."""
mt = db.session.get(ModelType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model type with ID {type_id} not found',
http_code=404
)
# Check if any model uses this type
from shopdb.core.models import Model
if Model.query.filter_by(modeltypeid=type_id).first():
return error_response(
ErrorCodes.CONFLICT,
'Cannot delete model type: models are using it',
http_code=409
)
mt.isactive = False
db.session.commit()
return success_response(message='Model type deleted')

View File

@@ -0,0 +1,146 @@
"""Operating Systems API endpoints - Full CRUD."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import OperatingSystem
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.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
operatingsystems_bp = Blueprint('operatingsystems', __name__)
@operatingsystems_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_operatingsystems():
"""List all operating systems."""
page, per_page = get_pagination_params(request)
query = OperatingSystem.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(OperatingSystem.isactive == True)
# Exact-match natural-key lookup for idempotent import. Legacy OS rows have
# only a name; pair with ?osversion= when versions are tracked separately.
if exactosname := request.args.get('osname'):
query = query.filter(OperatingSystem.osname == exactosname)
if exactosversion := request.args.get('osversion'):
query = query.filter(OperatingSystem.osversion == exactosversion)
if search := request.args.get('search'):
query = query.filter(OperatingSystem.osname.ilike(f'%{search}%'))
query = query.order_by(OperatingSystem.osname)
items, total = paginate_query(query, page, per_page)
data = [os.to_dict() for os in items]
return paginated_response(data, page, per_page, total)
@operatingsystems_bp.route('/<int:os_id>', methods=['GET'])
@jwt_required(optional=True)
def get_operatingsystem(os_id: int):
"""Get a single operating system."""
os = db.session.get(OperatingSystem, os_id)
if not os:
return error_response(
ErrorCodes.NOT_FOUND,
f'Operating system with ID {os_id} not found',
http_code=404
)
return success_response(os.to_dict())
@operatingsystems_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_operatingsystem():
"""Create a new operating system."""
data = request.get_json()
if not data or not data.get('osname'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'osname is required')
existing = OperatingSystem.query.filter_by(
osname=data['osname'],
osversion=data.get('osversion')
).first()
if existing:
return error_response(
ErrorCodes.CONFLICT,
f"Operating system '{data['osname']} {data.get('osversion', '')}' already exists",
http_code=409
)
os = OperatingSystem(
osname=data['osname'],
osversion=data.get('osversion'),
architecture=data.get('architecture'),
endoflife=data.get('endoflife')
)
db.session.add(os)
apply_import_timestamps(os, data)
db.session.commit()
return success_response(os.to_dict(), message='Operating system created', http_code=201)
@operatingsystems_bp.route('/<int:os_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_operatingsystem(os_id: int):
"""Update an operating system."""
os = db.session.get(OperatingSystem, os_id)
if not os:
return error_response(
ErrorCodes.NOT_FOUND,
f'Operating system with ID {os_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
for key in ['osname', 'osversion', 'architecture', 'endoflife', 'isactive']:
if key in data:
setattr(os, key, data[key])
apply_import_timestamps(os, data)
db.session.commit()
return success_response(os.to_dict(), message='Operating system updated')
@operatingsystems_bp.route('/<int:os_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_operatingsystem(os_id: int):
"""Delete (deactivate) an operating system."""
os = db.session.get(OperatingSystem, os_id)
if not os:
return error_response(
ErrorCodes.NOT_FOUND,
f'Operating system with ID {os_id} not found',
http_code=404
)
os.isactive = False
db.session.commit()
return success_response(message='Operating system deleted')

View File

@@ -0,0 +1,74 @@
"""Plugin introspection + enable/disable API."""
from flask import Blueprint, current_app, request
from flask_jwt_extended import jwt_required
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
plugins_bp = Blueprint('plugins', __name__)
@plugins_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_plugins():
"""List all discovered plugins (enabled or not) with their metadata and
the framework contract version."""
from shopdb import __contract_version__
pm = current_app.extensions.get('plugin_manager')
plugins = pm.discover_available() if pm else []
plugins.sort(key=lambda p: p['name'])
return success_response({
'contract_version': __contract_version__,
'count': len(plugins),
'plugins': plugins,
})
@plugins_bp.route('/enabled', methods=['GET'])
@jwt_required(optional=True)
def list_enabled_plugins():
"""Return just the names of enabled plugins as a flat array.
Cheap registry read (no DB). Exposed to anonymous callers on purpose:
the navigation endpoint already leaks the same enabled/disabled signal,
and the frontend needs it (including unauthenticated kiosk routes like
/tv) to gate plugin-owned routes. No metadata beyond the names.
"""
pm = current_app.extensions.get('plugin_manager')
names = pm.registry.get_enabled_plugins() if pm else []
return success_response(sorted(names))
@plugins_bp.route('/<name>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def set_plugin_enabled(name: str):
"""Enable or disable a plugin. Takes effect on the next app restart for
route/navigation changes."""
data = request.get_json() or {}
if 'enabled' not in data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'enabled is required')
pm = current_app.extensions.get('plugin_manager')
if not pm:
return error_response(ErrorCodes.INTERNAL_ERROR,
'Plugin manager unavailable', http_code=500)
want = bool(data['enabled'])
ok = pm.enable_plugin(name) if want else pm.disable_plugin(name)
if not ok:
# enable/disable refused (unknown plugin, or a dependency conflict)
return error_response(
ErrorCodes.CONFLICT,
f"Could not {'enable' if want else 'disable'} '{name}' "
f"(check it exists and dependencies allow it)",
http_code=409
)
return success_response(
{'name': name, 'enabled': want},
message=f"Plugin {'enabled' if want else 'disabled'} "
f"(restart to apply route changes)"
)

108
shopdb/core/api/pluginui.py Normal file
View File

@@ -0,0 +1,108 @@
"""Plugin frontend-contribution API endpoints (ADR-010).
Consumers for the four data-only presentation hooks on BasePlugin:
get_settings_cards, get_asset_panels, get_map_overlays, get_asset_presentation.
Every endpoint copies the dashboard-widgets consumer semantics: skip disabled
plugins, tag each contributed dict with its originating plugin name, fail loud
in dev/test, isolate a broken plugin in prod. All routes are GET + jwt-optional,
matching the other read-only core endpoints; they expose data, not mutations.
"""
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset
from shopdb.utils.responses import success_response, error_response, ErrorCodes
pluginui_bp = Blueprint('pluginui', __name__)
def _collect(hookname):
"""Aggregate a data-only hook across enabled plugins.
Same access pattern as dashboard.get_widgets: skip disabled plugins, inject
the plugin name on every returned dict, re-raise in dev/test, log-and-isolate
a broken plugin in prod. Returns the merged list (unsorted).
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
return []
merged = []
for name, plugin in pm.get_all_plugins().items():
if not pm.registry.is_enabled(name):
continue
try:
for entry in getattr(plugin, hookname)() or []:
entry['plugin'] = name
merged.append(entry)
except Exception:
# fail loud in dev/test, isolate broke plugin in prod
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise
current_app.logger.exception('Plugin %s %s failed', name, hookname)
return merged
@pluginui_bp.route('/settings-cards', methods=['GET'])
@jwt_required(optional=True)
def settings_cards():
"""Merge enabled plugins' settings-catalog cards (get_settings_cards hook).
The frontend merges these into the core settingsNav catalog and renders
them in the settings rail + landing overview.
"""
cards = _collect('get_settings_cards')
cards.sort(key=lambda card: card.get('position', 99))
return success_response(cards)
@pluginui_bp.route('/asset-panels', methods=['GET'])
@jwt_required(optional=True)
def asset_panels():
"""Return the asset-detail panels matching one asset's type (get_asset_panels).
Query param assetid is required. A panel matches when its assettypes list
contains the asset's type key or the wildcard '*'.
"""
assetid = request.args.get('assetid', type=int)
if not assetid:
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetid is required')
asset = db.session.get(Asset, assetid)
if not asset:
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
assettype = asset.assettype.assettype if asset.assettype else None
matched = []
for panel in _collect('get_asset_panels'):
assettypes = panel.get('assettypes') or []
if '*' in assettypes or (assettype and assettype in assettypes):
matched.append(panel)
matched.sort(key=lambda panel: panel.get('position', 99))
return success_response(matched)
@pluginui_bp.route('/map-overlays', methods=['GET'])
@jwt_required(optional=True)
def map_overlays():
"""Merge enabled plugins' map overlay declarations (get_map_overlays hook).
The map fetches each overlay endpoint and decorates already-placed markers.
"""
overlays = _collect('get_map_overlays')
overlays.sort(key=lambda overlay: overlay.get('position', 99))
return success_response(overlays)
@pluginui_bp.route('/asset-presentation', methods=['GET'])
@jwt_required(optional=True)
def asset_presentation():
"""Merge enabled plugins' asset-type presentation entries (hook).
Search rows and cross-links use these to pick a type's icon and detail route.
"""
entries = _collect('get_asset_presentation')
return success_response(entries)

636
shopdb/core/api/reports.py Normal file
View File

@@ -0,0 +1,636 @@
"""Reports API endpoints."""
import csv
import io
from datetime import datetime, timezone
from flask import Blueprint, request, Response, current_app
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import (
Asset, AssetType, AssetStatus,
Application
)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_permission
reports_bp = Blueprint('reports', __name__)
def generate_csv(data: list, columns: list) -> str:
"""Generate CSV string from data."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(columns)
for row in data:
writer.writerow([row.get(col, '') for col in columns])
return output.getvalue()
# =============================================================================
# Report: Machines by Type
# =============================================================================
@reports_bp.route('/machines-by-type', methods=['GET'])
@jwt_required(optional=True)
def machines_by_type():
"""
Report: Machine count grouped by machine type.
Query parameters:
- businessunitid: Filter by business unit
- format: 'json' (default) or 'csv'
"""
try:
from plugins.machines.models import Machine, MachineType
except ImportError:
return error_response(
ErrorCodes.NOT_FOUND,
'Machines plugin not installed',
http_code=404
)
query = db.session.query(
MachineType.machinetype,
MachineType.description,
db.func.count(Machine.machineid).label('count')
).outerjoin(Machine, Machine.machinetypeid == MachineType.machinetypeid
).outerjoin(Asset, Asset.assetid == Machine.assetid
).filter(
db.or_(Asset.isactive == True, Asset.assetid.is_(None)),
MachineType.isactive == True
)
# Business unit filter
if bu_id := request.args.get('businessunitid'):
query = query.filter(Asset.businessunitid == int(bu_id))
# Dualpath single-machine collapse: exclude the secondary bay so a dual-bay
# pair counts once per type bucket. Gated on the site setting (default on).
from shopdb.core.services.dualpath import (
resolve_dualpath_pairs, dualpath_single_machine_enabled)
if dualpath_single_machine_enabled():
secondaryassetids = resolve_dualpath_pairs().secondaryassetids
if secondaryassetids:
query = query.filter(
db.or_(Machine.assetid.is_(None),
Machine.assetid.notin_(secondaryassetids)))
query = query.group_by(
MachineType.machinetypeid,
MachineType.machinetype,
MachineType.description
).order_by(MachineType.machinetype)
results = query.all()
data = [{'machinetype': r.machinetype, 'description': r.description or '', 'count': r.count} for r in results]
total = sum(r['count'] for r in data)
if request.args.get('format') == 'csv':
csv_data = generate_csv(data, ['machinetype', 'description', 'count'])
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=machines_by_type.csv'}
)
return success_response({
'report': 'machines_by_type',
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'total': total
})
# =============================================================================
# Report: Assets by Status
# =============================================================================
@reports_bp.route('/assets-by-status', methods=['GET'])
@jwt_required(optional=True)
def assets_by_status():
"""
Report: Asset count grouped by status.
Query parameters:
- assettypeid: Filter by asset type
- businessunitid: Filter by business unit
- format: 'json' (default) or 'csv'
"""
query = db.session.query(
AssetStatus.status,
AssetStatus.color,
db.func.count(Asset.assetid).label('count')
).outerjoin(Asset, Asset.statusid == AssetStatus.statusid
).filter(
db.or_(Asset.isactive == True, Asset.assetid.is_(None)),
AssetStatus.isactive == True
)
# Asset type filter
if type_id := request.args.get('assettypeid'):
query = query.filter(Asset.assettypeid == int(type_id))
# Business unit filter
if bu_id := request.args.get('businessunitid'):
query = query.filter(Asset.businessunitid == int(bu_id))
query = query.group_by(
AssetStatus.statusid,
AssetStatus.status,
AssetStatus.color
).order_by(AssetStatus.status)
results = query.all()
data = [{'status': r.status, 'color': r.color or '', 'count': r.count} for r in results]
total = sum(r['count'] for r in data)
if request.args.get('format') == 'csv':
csv_data = generate_csv(data, ['status', 'count'])
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=assets_by_status.csv'}
)
return success_response({
'report': 'assets_by_status',
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'total': total
})
# =============================================================================
# Report: KB Popularity
# =============================================================================
@reports_bp.route('/kb-popularity', methods=['GET'])
@jwt_required(optional=True)
def kb_popularity():
"""
Report: Most clicked knowledge base articles.
Query parameters:
- limit: Number of results (default 20)
- format: 'json' (default) or 'csv'
"""
limit = min(int(request.args.get('limit', 20)), 100)
# Knowledge Base is a plugin; degrade gracefully if it is not installed.
try:
from plugins.knowledgebase.models import KnowledgeBase
except ImportError:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'KB popularity requires the knowledgebase plugin',
http_code=503)
articles = KnowledgeBase.query.filter(
KnowledgeBase.isactive == True
).order_by(
KnowledgeBase.clicks.desc()
).limit(limit).all()
data = []
for kb in articles:
data.append({
'linkid': kb.linkid,
'shortdescription': kb.shortdescription,
'application': kb.application.appname if kb.application else None,
'clicks': kb.clicks or 0,
'linkurl': kb.linkurl
})
if request.args.get('format') == 'csv':
csv_data = generate_csv(data, ['linkid', 'shortdescription', 'application', 'clicks', 'linkurl'])
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=kb_popularity.csv'}
)
return success_response({
'report': 'kb_popularity',
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'total': len(data)
})
# =============================================================================
# Report: Software Compliance
# =============================================================================
@reports_bp.route('/software-compliance', methods=['GET'])
@jwt_required(optional=True)
def software_compliance():
"""
Report: Required applications vs installed (per PC).
Shows which PCs have required applications installed.
Query parameters:
- appid: Filter to specific application
- format: 'json' (default) or 'csv'
"""
# Install tracking lives in the computers plugin; degrade gracefully if
# it is not installed.
try:
from plugins.computers.models import Computer, ComputerInstalledApp
except ImportError:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Software compliance requires the computers plugin',
http_code=503)
# Get required applications
required_apps = Application.query.filter(
Application.isactive == True,
Application.isrequired == True
).all()
if not required_apps:
return success_response({
'report': 'software_compliance',
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': [],
'message': 'No required applications defined'
})
app_filter = request.args.get('appid')
data = []
for app in required_apps:
if app_filter and str(app.appid) != app_filter:
continue
# Get all PCs (computers)
total_pcs = Computer.query.join(
Asset, Asset.assetid == Computer.assetid
).filter(Asset.isactive == True).count()
# Get PCs with this app installed
installed_count = db.session.query(ComputerInstalledApp).join(
Computer, Computer.computerid == ComputerInstalledApp.computerid
).join(
Asset, Asset.assetid == Computer.assetid
).filter(
ComputerInstalledApp.appid == app.appid,
ComputerInstalledApp.isactive == True,
Asset.isactive == True
).count()
# Get list of non-compliant PCs
compliant_pc_ids = db.session.query(ComputerInstalledApp.computerid).filter(
ComputerInstalledApp.appid == app.appid,
ComputerInstalledApp.isactive == True
).subquery()
non_compliant_pcs = Computer.query.join(
Asset, Asset.assetid == Computer.assetid
).filter(
Asset.isactive == True,
~Computer.computerid.in_(compliant_pc_ids)
).limit(100).all()
compliance_rate = (installed_count / total_pcs * 100) if total_pcs > 0 else 0
data.append({
'appid': app.appid,
'appname': app.appname,
'totalpcs': total_pcs,
'installed': installed_count,
'notinstalled': total_pcs - installed_count,
'compliancerate': round(compliance_rate, 1),
'noncompliantpcs': [
{'computerid': pc.computerid,
'hostname': pc.hostname or (pc.asset.assetnumber if pc.asset else None)}
for pc in non_compliant_pcs
]
})
if request.args.get('format') == 'csv':
# Simplified CSV (no nested data)
csv_rows = []
for item in data:
csv_rows.append({
'appid': item['appid'],
'appname': item['appname'],
'totalpcs': item['totalpcs'],
'installed': item['installed'],
'notinstalled': item['notinstalled'],
'compliancerate': item['compliancerate']
})
csv_data = generate_csv(csv_rows, ['appid', 'appname', 'totalpcs', 'installed', 'notinstalled', 'compliancerate'])
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=software_compliance.csv'}
)
return success_response({
'report': 'software_compliance',
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'total': len(data)
})
# =============================================================================
# Report: Asset Inventory Summary
# =============================================================================
@reports_bp.route('/asset-inventory', methods=['GET'])
@jwt_required(optional=True)
def asset_inventory():
"""
Report: Complete asset inventory summary.
Query parameters:
- businessunitid: Filter by business unit
- locationid: Filter by location
- format: 'json' (default) or 'csv'
"""
# By asset type
by_type = db.session.query(
AssetType.assettype,
db.func.count(Asset.assetid).label('count')
).outerjoin(Asset, Asset.assettypeid == AssetType.assettypeid
).filter(
db.or_(Asset.isactive == True, Asset.assetid.is_(None)),
AssetType.isactive == True
)
if bu_id := request.args.get('businessunitid'):
by_type = by_type.filter(Asset.businessunitid == int(bu_id))
if loc_id := request.args.get('locationid'):
by_type = by_type.filter(Asset.locationid == int(loc_id))
by_type = by_type.group_by(AssetType.assettype).all()
# By status
by_status = db.session.query(
AssetStatus.status,
AssetStatus.color,
db.func.count(Asset.assetid).label('count')
).outerjoin(Asset, Asset.statusid == AssetStatus.statusid
).filter(
db.or_(Asset.isactive == True, Asset.assetid.is_(None)),
AssetStatus.isactive == True
)
if bu_id := request.args.get('businessunitid'):
by_status = by_status.filter(Asset.businessunitid == int(bu_id))
if loc_id := request.args.get('locationid'):
by_status = by_status.filter(Asset.locationid == int(loc_id))
by_status = by_status.group_by(AssetStatus.status, AssetStatus.color).all()
# By location
from shopdb.core.models import Location
by_location = db.session.query(
Location.locationname,
db.func.count(Asset.assetid).label('count')
).outerjoin(Asset, Asset.locationid == Location.locationid
).filter(
db.or_(Asset.isactive == True, Asset.assetid.is_(None)),
Location.isactive == True
)
if bu_id := request.args.get('businessunitid'):
by_location = by_location.filter(Asset.businessunitid == int(bu_id))
by_location = by_location.group_by(Location.locationname).all()
data = {
'bytype': [{'type': r.assettype, 'count': r.count} for r in by_type],
'bystatus': [{'status': r.status, 'color': r.color, 'count': r.count} for r in by_status],
'bylocation': [{'location': r.locationname, 'count': r.count} for r in by_location]
}
total = sum(r['count'] for r in data['bytype'])
if request.args.get('format') == 'csv':
# Create multiple sections in CSV
csv_output = io.StringIO()
csv_output.write("Asset Inventory Report\n")
csv_output.write(f"Generated: {datetime.now(timezone.utc).replace(tzinfo=None).isoformat()}\n\n")
csv_output.write("By Type\n")
csv_output.write("Type,Count\n")
for r in data['bytype']:
csv_output.write(f"{r['type']},{r['count']}\n")
csv_output.write("\nBy Status\n")
csv_output.write("Status,Count\n")
for r in data['bystatus']:
csv_output.write(f"{r['status']},{r['count']}\n")
csv_output.write("\nBy Location\n")
csv_output.write("Location,Count\n")
for r in data['bylocation']:
csv_output.write(f"{r['location']},{r['count']}\n")
return Response(
csv_output.getvalue(),
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=asset_inventory.csv'}
)
return success_response({
'report': 'asset_inventory',
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'total': total
})
# =============================================================================
# Available Reports List
# =============================================================================
# =============================================================================
# Report: PC-Machine Relationships
# =============================================================================
@reports_bp.route('/pc-relationships', methods=['GET'])
@jwt_required(optional=True)
def pc_relationships():
"""
Report: PCs with relationships to shop floor machines.
Returns machine number, vendor, model, PC hostname, and PC IP address
for all active PC-machine relationships.
Query parameters:
- format: 'json' (default) or 'csv'
"""
# Asset relationships where a computer (source) relates to a machine
# (target) - the asset-model equivalent of the legacy PC->machine links.
sql = db.text("""
SELECT
eq.assetnumber AS machine_number,
v.vendor AS vendor,
mo.modelnumber AS model,
COALESCE(cpc.hostname, pc.assetnumber) AS hostname,
c.ipaddress AS ip
FROM assetrelationships ar
JOIN assets pc ON ar.sourceassetid = pc.assetid
JOIN computers cpc ON cpc.assetid = pc.assetid
JOIN assets eq ON ar.targetassetid = eq.assetid
JOIN machines eqx ON eqx.assetid = eq.assetid
LEFT JOIN communications c ON c.assetid = pc.assetid AND c.isprimary = 1 AND c.comtypeid = 1
LEFT JOIN models mo ON eqx.modelnumberid = mo.modelnumberid
LEFT JOIN vendors v ON mo.vendorid = v.vendorid
WHERE ar.isactive = 1
AND eq.assetnumber IS NOT NULL AND eq.assetnumber != ''
ORDER BY eq.assetnumber
""")
results = db.session.execute(sql).fetchall()
data = [{
'machine_number': r.machine_number,
'vendor': r.vendor or '',
'model': r.model or '',
'hostname': r.hostname or '',
'ip': r.ip or ''
} for r in results]
if request.args.get('format') == 'csv':
csv_data = generate_csv(data, ['machine_number', 'vendor', 'model', 'hostname', 'ip'])
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=pc_relationships.csv'}
)
return success_response({
'report': 'pc_relationships',
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'total': len(data)
})
@reports_bp.route('/email', methods=['POST'])
@jwt_required()
@require_permission('reports.export')
def email_report():
"""Email a report's data as an HTML table (on-demand delivery).
Request:
{
"subject": "Warranty Report",
"columns": [{"key": "vendor", "label": "Vendor"}, ...],
"rows": [{"vendor": "...", ...}, ...],
"intro": "optional lead paragraph",
"to": "addr@example.com" // optional; defaults to alert_recipients
}
Recipients default to the site's Alert Recipients when `to` is omitted, so
this doubles as the alert delivery path. There is no scheduler in this app:
sending is on-demand. To automate it, point an external cron job at this
endpoint using an API token (PAT) scoped to reports.export.
"""
from shopdb.utils.mailer import get_smtp_config, render_table_email, try_send
data = request.get_json() or {}
subject = data.get('subject') or 'ShopDB report'
columns = data.get('columns') or []
rows = data.get('rows') or []
config = get_smtp_config()
recipient = data.get('to') or config['alert_recipients']
if not config['enabled'] or not config['host']:
return success_response(
{'sent': False, 'reason': 'notconfigured'},
message='Email is not configured (SMTP disabled or host unset).')
if not recipient:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'No recipient. Provide "to" or set Alert Recipients.')
html, text = render_table_email(
subject, columns, rows, intro=data.get('intro'))
ok, error = try_send(recipient, subject, html, text=text)
if ok:
return success_response({'sent': True}, message='Report emailed.')
return success_response(
{'sent': False, 'error': error},
message='Report email failed: ' + (error or 'unknown error'))
@reports_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_reports():
"""List all available reports."""
reports = [
{
'id': 'machines-by-type',
'name': 'Machines by Type',
'description': 'Machine count grouped by machine type',
'endpoint': '/api/reports/machines-by-type',
'category': 'inventory'
},
{
'id': 'assets-by-status',
'name': 'Assets by Status',
'description': 'Asset count grouped by status',
'endpoint': '/api/reports/assets-by-status',
'category': 'inventory'
},
{
'id': 'kb-popularity',
'name': 'KB Popularity',
'description': 'Most clicked knowledge base articles',
'endpoint': '/api/reports/kb-popularity',
'category': 'usage'
},
{
'id': 'software-compliance',
'name': 'Software Compliance',
'description': 'Required applications vs installed',
'endpoint': '/api/reports/software-compliance',
'category': 'compliance'
},
{
'id': 'asset-inventory',
'name': 'Asset Inventory Summary',
'description': 'Complete asset inventory breakdown',
'endpoint': '/api/reports/asset-inventory',
'category': 'inventory'
},
{
'id': 'pc-relationships',
'name': 'PC-Machine Relationships',
'description': 'PCs with relationships to shop floor machines',
'endpoint': '/api/reports/pc-relationships',
'category': 'inventory'
}
]
# Merge report cards contributed by enabled plugins (get_reports hook).
# Same access pattern as dashboard.get_navigation: skip disabled plugins,
# fail loud in dev/test, isolate a broken plugin in prod.
pm = current_app.extensions.get('plugin_manager')
if pm:
for name, plugin in pm.get_all_plugins().items():
if not pm.registry.is_enabled(name):
continue
try:
for entry in plugin.get_reports() or []:
entry['plugin'] = name
reports.append(entry)
except Exception:
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise
current_app.logger.exception(
'Plugin %s get_reports failed', name)
return success_response({
'reports': reports,
'total': len(reports)
})

912
shopdb/core/api/search.py Normal file
View File

@@ -0,0 +1,912 @@
"""Global search API endpoint with full search parity."""
import re
import ipaddress
import logging
from datetime import datetime, timezone
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from sqlalchemy.orm import joinedload
from shopdb.extensions import db
from shopdb.core.models import (
Application, Setting,
Asset, AssetType, Communication, Vendor, Model,
CustomField, CustomFieldValue
)
from shopdb.core.api.settings import get_cached_settings
from shopdb.utils.responses import success_response
logger = logging.getLogger(__name__)
search_bp = Blueprint('search', __name__)
def _require_enabled(name):
"""Raise ImportError when the named plugin is disabled.
Each plugin-scoped search block already catches ImportError and skips the
domain, so a disabled plugin is treated exactly like an absent one: its rows
drop out of search results. Honors runtime enable/disable.
"""
pm = current_app.extensions.get('plugin_manager')
if pm and not pm.registry.is_enabled(name):
raise ImportError(f'{name} plugin disabled')
# Shipped GE defaults. Settings override these per-site; identical fallbacks
# live here so this consumer works even if the settings seed has not run.
SERVICENOW_URL_DEFAULT = (
'https://geaerospaceqa.service-now.com/now/nav/ui/search/'
'0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/'
'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
)
SERVICENOW_PREFIXES_DEFAULT = 'GEINC,GECHG,GERIT,GESCT'
EMPLOYEEID_PATTERN_DEFAULT = r'^\d{9}$'
def _get_search_integrations():
"""Resolve the settings-driven search integration config.
Reads employeeid_pattern, servicenow_ticket_prefixes, servicenow_enabled
and servicenow_search_url from cached settings, falling back to the shipped
GE defaults for any missing key. An invalid employeeid_pattern regex falls
back to the default rather than raising (search must never 500 on bad
config). ServiceNow is inactive when disabled, when the URL is blank, or
when no ticket prefixes are configured.
"""
settings = get_cached_settings()
# Employee-ID pattern. Bad regex falls back so search never 500s.
pattern = settings.get('employeeid_pattern') or EMPLOYEEID_PATTERN_DEFAULT
try:
employeeid_re = re.compile(pattern)
except re.error:
employeeid_re = re.compile(EMPLOYEEID_PATTERN_DEFAULT)
# Ticket prefixes -> case-insensitive alternation built at request time.
prefixes_raw = settings.get('servicenow_ticket_prefixes')
if prefixes_raw is None:
prefixes_raw = SERVICENOW_PREFIXES_DEFAULT
prefixes = [p.strip() for p in str(prefixes_raw).split(',') if p.strip()]
servicenow_enabled = settings.get('servicenow_enabled')
if servicenow_enabled is None:
servicenow_enabled = True
servicenow_url = settings.get('servicenow_search_url')
if servicenow_url is None:
servicenow_url = SERVICENOW_URL_DEFAULT
servicenow_active = bool(servicenow_enabled) and bool(servicenow_url) and bool(prefixes)
prefix_re = None
if servicenow_active:
alternation = '|'.join(re.escape(p) for p in prefixes)
prefix_re = re.compile(r'^(' + alternation + r')\d+', re.IGNORECASE)
return {
'employeeid_re': employeeid_re,
'prefix_re': prefix_re,
'servicenow_active': servicenow_active,
'servicenow_url': servicenow_url,
}
def _classify_query(query, integrations):
"""Analyze the query string to determine its nature."""
prefix_re = integrations['prefix_re']
sn_match = prefix_re.match(query) if prefix_re else None
return {
'is_ip': bool(re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query)),
'is_sso': bool(integrations['employeeid_re'].match(query)),
'is_servicenow': bool(sn_match),
'servicenow_prefix': sn_match.group(1) if sn_match else None,
'is_fqdn': bool(re.match(r'^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$', query)),
}
def _get_asset_result(asset, query, relevance=None):
"""Build a search result dict from an Asset object."""
asset_type_name = asset.assettype.assettype if asset.assettype else 'asset'
plugin_id = asset.assetid
if asset_type_name == 'machine' and hasattr(asset, 'machine') and asset.machine:
plugin_id = asset.machine.machineid
elif asset_type_name == 'computer' and hasattr(asset, 'computer') and asset.computer:
plugin_id = asset.computer.computerid
elif asset_type_name == 'network_device' and hasattr(asset, 'network_device') and asset.network_device:
plugin_id = asset.network_device.networkdeviceid
elif asset_type_name == 'printer' and hasattr(asset, 'printer') and asset.printer:
plugin_id = asset.printer.printerid
elif asset_type_name == 'measuring_tool' and hasattr(asset, 'measuringtool') and asset.measuringtool:
plugin_id = asset.measuringtool.measuringtoolid
url_map = {
'machine': f"/machines/{plugin_id}",
'computer': f"/pcs/{plugin_id}",
'network_device': f"/network/{plugin_id}",
'printer': f"/printers/{plugin_id}",
'measuring_tool': f"/measuringtools/{plugin_id}",
}
url = url_map.get(asset_type_name, f"/assets/{asset.assetid}")
display_name = asset.display_name
subtitle = None
if asset.name and asset.assetnumber != asset.name:
subtitle = asset.assetnumber
location_name = asset.location.locationname if asset.location else None
if relevance is None:
relevance = 15
return {
'type': asset_type_name,
'id': plugin_id,
'title': display_name,
'subtitle': subtitle,
'location': location_name,
'url': url,
'relevance': relevance
}
def _search_applications(query, search_term):
"""Search Applications by name and description."""
results = []
try:
apps = Application.query.filter(
Application.isactive == True,
db.or_(
Application.appname.ilike(search_term),
Application.appdescription.ilike(search_term)
)
).limit(10).all()
for app in apps:
relevance = 20
if query.lower() == app.appname.lower():
relevance = 100
elif query.lower() in app.appname.lower():
relevance = 50
results.append({
'type': 'application',
'id': app.appid,
'title': app.appname,
'subtitle': app.appdescription[:100] if app.appdescription else None,
'url': f"/applications/{app.appid}",
'relevance': relevance
})
except Exception as e:
logger.error(f"Application search failed: {e}")
return results
def _search_knowledgebase(query, search_term):
"""Search Knowledge Base by description and keywords."""
results = []
try:
_require_enabled('knowledgebase')
from plugins.knowledgebase.models import KnowledgeBase
kb_articles = KnowledgeBase.query.filter(
KnowledgeBase.isactive == True,
db.or_(
KnowledgeBase.shortdescription.ilike(search_term),
KnowledgeBase.keywords.ilike(search_term)
)
).limit(20).all()
for kb in kb_articles:
relevance = 10 + (kb.clicks or 0) * 0.1
if kb.keywords and query.lower() in kb.keywords.lower():
relevance += 15
results.append({
'type': 'knowledgebase',
'id': kb.linkid,
'title': kb.shortdescription,
'subtitle': kb.application.appname if kb.application else None,
'url': f"/knowledgebase/{kb.linkid}",
'linkurl': kb.linkurl,
'relevance': relevance
})
except ImportError:
pass # knowledgebase plugin absent or disabled
except Exception as e:
logger.error(f"KnowledgeBase search failed: {e}")
return results
def _employee_rows(search_term):
"""Employee rows matching search_term, from the SELFHOSTED directory table
or the external HR DB per the employee_directory_mode setting. Returns dicts
shaped like the external query (SSO/First_Name/Last_Name/Team/Role)."""
settings = get_cached_settings()
mode = (settings.get('employee_directory_mode') or 'external').lower()
if mode == 'selfhosted':
try:
from plugins.employees.models import DirectoryEmployee
except ImportError:
return []
rows = DirectoryEmployee.query.filter(
db.or_(
DirectoryEmployee.firstname.ilike(search_term),
DirectoryEmployee.lastname.ilike(search_term),
db.cast(DirectoryEmployee.sso, db.String).ilike(search_term),
)
).order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname).limit(10).all()
return [{'SSO': e.sso, 'First_Name': e.firstname or '',
'Last_Name': e.lastname or '', 'Team': e.team, 'Role': e.role}
for e in rows]
# External HR DB: shared env-backed connection helper; never hardcode creds.
from shopdb.utils.employee_db import employee_connection
emp_conn = employee_connection()
with emp_conn.cursor() as cur:
cur.execute('''
SELECT SSO, First_Name, Last_Name, Team, Role
FROM employees
WHERE First_Name LIKE %s OR Last_Name LIKE %s OR CAST(SSO AS CHAR) LIKE %s
ORDER BY Last_Name, First_Name LIMIT 10
''', (search_term, search_term, search_term))
rows = cur.fetchall()
emp_conn.close()
return rows
def _search_employees(query, search_term):
"""Search employees (selfhosted directory or external HR DB, per mode)."""
results = []
try:
for emp in _employee_rows(search_term):
full_name = f"{emp['First_Name'].strip()} {emp['Last_Name'].strip()}"
sso_str = str(emp['SSO'])
relevance = 20
if query == sso_str:
relevance = 100
elif query.lower() == full_name.lower():
relevance = 95
elif query.lower() in full_name.lower():
relevance = 60
results.append({
'type': 'employee',
'id': emp['SSO'],
'title': full_name,
'subtitle': emp.get('Team') or emp.get('Role') or f"SSO: {sso_str}",
'url': f"/employees/{emp['SSO']}",
'relevance': relevance
})
except Exception as e:
logger.error(f"Employee search failed: {e}")
return results
def _search_assets(query, search_term):
"""Search unified Assets table by number, name, serial, notes."""
results = []
try:
assets = Asset.query.join(AssetType).options(
joinedload(Asset.assettype),
joinedload(Asset.location),
).filter(
Asset.isactive == True,
db.or_(
Asset.assetnumber.ilike(search_term),
Asset.name.ilike(search_term),
Asset.serialnumber.ilike(search_term),
Asset.notes.ilike(search_term)
)
).limit(15).all()
for asset in assets:
relevance = 15
if asset.assetnumber and query.lower() == asset.assetnumber.lower():
relevance = 100
elif asset.name and query.lower() == asset.name.lower():
relevance = 90
elif asset.serialnumber and query.lower() == asset.serialnumber.lower():
relevance = 85
elif asset.name and query.lower() in asset.name.lower():
relevance = 50
results.append(_get_asset_result(asset, query, relevance))
except Exception as e:
logger.error(f"Asset search failed: {e}")
return results
def _search_measuringtools(query, search_term):
"""Search measuring tools, including the gaugelabreference identifier.
The generic asset search already matches number/name/serial across all asset
types; this gated searcher adds gaugelabreference (the gage tag the gage lab
searches by) and drops out when the measuringtools plugin is disabled.
"""
results = []
try:
_require_enabled('measuringtools')
from plugins.measuringtools.models import MeasuringTool
assets = db.session.query(Asset).join(
MeasuringTool, MeasuringTool.assetid == Asset.assetid
).options(
joinedload(Asset.assettype),
joinedload(Asset.location),
).filter(
Asset.isactive == True,
db.or_(
Asset.assetnumber.ilike(search_term),
Asset.name.ilike(search_term),
Asset.serialnumber.ilike(search_term),
Asset.gaugelabreference.ilike(search_term),
)
).limit(15).all()
for asset in assets:
relevance = 15
if asset.assetnumber and query.lower() == asset.assetnumber.lower():
relevance = 100
elif asset.gaugelabreference and query.lower() == asset.gaugelabreference.lower():
relevance = 90
elif asset.serialnumber and query.lower() == asset.serialnumber.lower():
relevance = 85
elif asset.name and query.lower() in asset.name.lower():
relevance = 50
results.append(_get_asset_result(asset, query, relevance))
except ImportError:
pass # measuringtools plugin absent or disabled
except Exception as e:
logger.error(f"Measuring tool search failed: {e}")
return results
def _search_customfields(query, search_term):
"""Search custom-field VALUES for fields flagged searchable.
Joins CustomFieldValue -> CustomField (searchable + active) -> Asset (active)
and emits a normal asset result routed to that asset's detail page. Results
are deduped against built-in-field asset matches by the shared (type, id) key
in global_search, and the asset's search_<type>_enabled domain toggle is
applied by the same end-of-request domain filter. The matched field label is
put in the subtitle so a value hit reads sensibly.
"""
results = []
try:
rows = db.session.query(Asset, CustomField).join(
CustomFieldValue, CustomFieldValue.assetid == Asset.assetid
).join(
CustomField, CustomField.fieldid == CustomFieldValue.fieldid
).options(
joinedload(Asset.assettype),
joinedload(Asset.location),
).filter(
Asset.isactive == True,
CustomField.searchable == True,
CustomField.isactive == True,
CustomFieldValue.value.ilike(search_term),
).limit(15).all()
for asset, field in rows:
result = _get_asset_result(asset, query, relevance=40)
result['subtitle'] = field.label
results.append(result)
except Exception as e:
logger.error(f"Custom field search failed: {e}")
return results
def _search_by_ip(query, search_term):
"""Search Communications table for IP address matches."""
results = []
try:
comms = Communication.query.filter(
Communication.ipaddress.ilike(search_term)
).options(
joinedload(Communication.asset).joinedload(Asset.assettype),
joinedload(Communication.asset).joinedload(Asset.location),
).limit(10).all()
seen_assets = set()
for comm in comms:
asset = comm.asset
if not asset or not asset.isactive or asset.assetid in seen_assets:
continue
seen_assets.add(asset.assetid)
relevance = 80 if query == comm.ipaddress else 40
result = _get_asset_result(asset, query, relevance)
result['subtitle'] = comm.ipaddress
results.append(result)
except Exception as e:
logger.error(f"IP search failed: {e}")
return results
def _search_subnets(query):
"""Find which subnet an IP address belongs to."""
results = []
try:
_require_enabled('network')
from plugins.network.models import Subnet
ip_obj = ipaddress.ip_address(query)
subnets = Subnet.query.filter(Subnet.isactive == True).all()
for subnet in subnets:
try:
network = ipaddress.ip_network(subnet.cidr, strict=False)
if ip_obj in network:
results.append({
'type': 'subnet',
'id': subnet.subnetid,
'title': f'{subnet.name} ({subnet.cidr})',
'subtitle': subnet.description or subnet.subnettype,
'url': f'/network',
'relevance': 70
})
except ValueError:
continue
except ImportError:
pass
except Exception as e:
logger.error(f"Subnet search failed: {e}")
return results
def _search_hostnames(query, search_term):
"""Search hostname fields across Computer, Printer, NetworkDevice."""
results = []
# Search Computers
try:
_require_enabled('computers')
from plugins.computers.models import Computer
computers = Computer.query.filter(
Computer.hostname.ilike(search_term)
).options(
joinedload(Computer.asset).joinedload(Asset.assettype),
joinedload(Computer.asset).joinedload(Asset.location),
).limit(10).all()
for comp in computers:
if comp.asset and comp.asset.isactive:
relevance = 85 if query.lower() == (comp.hostname or '').lower() else 40
result = _get_asset_result(comp.asset, query, relevance)
result['subtitle'] = comp.hostname
results.append(result)
except ImportError:
pass
except Exception as e:
logger.error(f"Computer hostname search failed: {e}")
# Search Printers
try:
_require_enabled('printers')
from plugins.printers.models import Printer
printers = Printer.query.filter(
db.or_(
Printer.hostname.ilike(search_term),
Printer.sharename.ilike(search_term),
Printer.windowsname.ilike(search_term),
)
).options(
joinedload(Printer.asset).joinedload(Asset.assettype),
joinedload(Printer.asset).joinedload(Asset.location),
).limit(10).all()
for printer in printers:
if printer.asset and printer.asset.isactive:
match_field = printer.hostname or printer.sharename or ''
relevance = 85 if query.lower() == match_field.lower() else 40
result = _get_asset_result(printer.asset, query, relevance)
result['subtitle'] = printer.hostname or printer.sharename
results.append(result)
except ImportError:
pass
except Exception as e:
logger.error(f"Printer hostname search failed: {e}")
# Search Network Devices
try:
_require_enabled('network')
from plugins.network.models import NetworkDevice
devices = NetworkDevice.query.filter(
NetworkDevice.hostname.ilike(search_term)
).options(
joinedload(NetworkDevice.asset).joinedload(Asset.assettype),
joinedload(NetworkDevice.asset).joinedload(Asset.location),
).limit(10).all()
for device in devices:
if device.asset and device.asset.isactive:
relevance = 85 if query.lower() == (device.hostname or '').lower() else 40
result = _get_asset_result(device.asset, query, relevance)
result['subtitle'] = device.hostname
results.append(result)
except ImportError:
pass
except Exception as e:
logger.error(f"Network device hostname search failed: {e}")
return results
def _search_notifications(query, search_term):
"""Search notifications with time-weighted relevance."""
results = []
try:
_require_enabled('notifications')
from plugins.notifications.models import Notification
notifications = Notification.query.options(
joinedload(Notification.notificationtype)
).filter(
db.or_(
Notification.notification.ilike(search_term),
Notification.ticketnumber.ilike(search_term)
)
).order_by(Notification.starttime.desc()).limit(15).all()
now = datetime.now(timezone.utc).replace(tzinfo=None)
for notif in notifications:
base_relevance = 20
if notif.ticketnumber and query.lower() == notif.ticketnumber.lower():
base_relevance = 85
# Time-weighted relevance
if notif.is_current:
base_relevance *= 3
elif notif.starttime and notif.starttime > now:
base_relevance *= 2
elif notif.endtime and (now - notif.endtime).days < 7:
base_relevance = int(base_relevance * 1.5)
results.append({
'type': 'notification',
'id': notif.notificationid,
'title': notif.title,
'subtitle': notif.notificationtype.typename if notif.notificationtype else None,
'url': f'/notifications',
'relevance': min(int(base_relevance), 100),
'ticketnumber': notif.ticketnumber,
'iscurrent': notif.is_current
})
except ImportError:
pass
except Exception as e:
logger.error(f"Notification search failed: {e}")
return results
def _search_vendor_model_type(query, search_term):
"""Search assets by vendor name, model name, or machine/device type name."""
results = []
# Machines: vendor, model, machinetype
try:
_require_enabled('machines')
from plugins.machines.models import Machine, MachineType
machine_assets = db.session.query(Asset).join(
Machine, Machine.assetid == Asset.assetid
).outerjoin(
Vendor, Machine.vendorid == Vendor.vendorid
).outerjoin(
Model, Machine.modelnumberid == Model.modelnumberid
).outerjoin(
MachineType, Machine.machinetypeid == MachineType.machinetypeid
).options(
joinedload(Asset.assettype),
joinedload(Asset.location),
).filter(
Asset.isactive == True,
db.or_(
Vendor.vendor.ilike(search_term),
Model.modelnumber.ilike(search_term),
MachineType.machinetype.ilike(search_term)
)
).limit(10).all()
for asset in machine_assets:
results.append(_get_asset_result(asset, query, 30))
except ImportError:
pass
except Exception as e:
logger.error(f"Machine vendor/model/type search failed: {e}")
# Printers: vendor, model, printertype
try:
_require_enabled('printers')
from plugins.printers.models import Printer, PrinterType
printer_assets = db.session.query(Asset).join(
Printer, Printer.assetid == Asset.assetid
).outerjoin(
Vendor, Printer.vendorid == Vendor.vendorid
).outerjoin(
Model, Printer.modelnumberid == Model.modelnumberid
).outerjoin(
PrinterType, Printer.printertypeid == PrinterType.printertypeid
).options(
joinedload(Asset.assettype),
joinedload(Asset.location),
).filter(
Asset.isactive == True,
db.or_(
Vendor.vendor.ilike(search_term),
Model.modelnumber.ilike(search_term),
PrinterType.printertype.ilike(search_term)
)
).limit(10).all()
for asset in printer_assets:
results.append(_get_asset_result(asset, query, 30))
except ImportError:
pass
except Exception as e:
logger.error(f"Printer vendor/model/type search failed: {e}")
# Network Devices: vendor, networkdevicetype
try:
_require_enabled('network')
from plugins.network.models import NetworkDevice, NetworkDeviceType
netdev_assets = db.session.query(Asset).join(
NetworkDevice, NetworkDevice.assetid == Asset.assetid
).outerjoin(
Vendor, NetworkDevice.vendorid == Vendor.vendorid
).outerjoin(
NetworkDeviceType, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid
).options(
joinedload(Asset.assettype),
joinedload(Asset.location),
).filter(
Asset.isactive == True,
db.or_(
Vendor.vendor.ilike(search_term),
NetworkDeviceType.networkdevicetype.ilike(search_term)
)
).limit(10).all()
for asset in netdev_assets:
results.append(_get_asset_result(asset, query, 30))
except ImportError:
pass
except Exception as e:
logger.error(f"Network device vendor/type search failed: {e}")
return results
def _check_smart_redirect(query, classification):
"""Check if query exactly matches a single entity for smart redirect."""
# Exact SSO match
if classification['is_sso']:
try:
# Shared env-backed connection helper; never hardcode creds.
from shopdb.utils.employee_db import employee_connection
emp_conn = employee_connection()
with emp_conn.cursor() as cur:
cur.execute(
'SELECT SSO, First_Name, Last_Name FROM employees WHERE SSO = %s LIMIT 1',
(query,)
)
emp = cur.fetchone()
emp_conn.close()
if emp:
name = f"{emp['First_Name'].strip()} {emp['Last_Name'].strip()}"
return {
'type': 'employee',
'url': f"/employees/{emp['SSO']}",
'label': name
}
except Exception:
pass
# Exact asset number match
try:
asset = Asset.query.options(
joinedload(Asset.assettype),
).filter(
Asset.assetnumber == query,
Asset.isactive == True
).first()
if asset:
result = _get_asset_result(asset, query)
return {
'type': result['type'],
'url': result['url'],
'label': asset.display_name
}
except Exception:
pass
# Exact printer CSF/share name
try:
_require_enabled('printers')
from plugins.printers.models import Printer
printer = Printer.query.options(
joinedload(Printer.asset).joinedload(Asset.assettype),
).filter(
db.or_(
Printer.sharename == query,
Printer.windowsname == query
)
).first()
if printer and printer.asset and printer.asset.isactive:
return {
'type': 'printer',
'url': f"/printers/{printer.printerid}",
'label': printer.sharename or printer.asset.display_name
}
except ImportError:
pass
except Exception:
pass
# Exact hostname match (FQDN or bare hostname)
hostname_plugins = []
try:
_require_enabled('computers')
from plugins.computers.models import Computer
hostname_plugins.append(('computer', Computer, 'computerid', '/pcs'))
except ImportError:
pass
try:
_require_enabled('printers')
from plugins.printers.models import Printer
hostname_plugins.append(('printer', Printer, 'printerid', '/printers'))
except ImportError:
pass
try:
_require_enabled('network')
from plugins.network.models import NetworkDevice
hostname_plugins.append(('network_device', NetworkDevice, 'networkdeviceid', '/network'))
except ImportError:
pass
for type_name, PluginModel, id_field, url_prefix in hostname_plugins:
try:
device = PluginModel.query.options(
joinedload(PluginModel.asset)
).filter(
PluginModel.hostname == query
).first()
if device and device.asset and device.asset.isactive:
return {
'type': type_name,
'url': f"{url_prefix}/{getattr(device, id_field)}",
'label': device.hostname
}
except Exception:
pass
# Exact IP match
if classification['is_ip']:
try:
comm = Communication.query.options(
joinedload(Communication.asset).joinedload(Asset.assettype)
).filter(
Communication.ipaddress == query
).first()
if comm and comm.asset and comm.asset.isactive:
result = _get_asset_result(comm.asset, query)
return {
'type': result['type'],
'url': result['url'],
'label': f"{comm.asset.display_name} ({comm.ipaddress})"
}
except Exception:
pass
return None
@search_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def global_search():
"""
Global search across multiple entity types.
Returns combined results from assets, applications, knowledge base,
employees, notifications, IP addresses, hostnames, and vendor/model/type.
Supports smart redirects and ServiceNOW ticket detection.
"""
query = request.args.get('q', '').strip()
if not query or len(query) < 2:
return success_response({
'results': [],
'query': query,
'message': 'Search query must be at least 2 characters'
})
if len(query) > 200:
return success_response({
'results': [],
'query': query[:200],
'message': 'Search query too long'
})
integrations = _get_search_integrations()
classification = _classify_query(query, integrations)
# ServiceNOW prefix detection - return redirect immediately
if classification['is_servicenow']:
from urllib.parse import quote
servicenow_url = integrations['servicenow_url'].format(ticket=quote(query))
return success_response({
'results': [],
'query': query,
'total': 0,
'counts': {},
'redirect': {
'type': 'servicenow',
'url': servicenow_url,
'label': f'Open {query} in ServiceNOW'
}
})
results = []
search_term = f'%{query}%'
# Run all search domains
results.extend(_search_applications(query, search_term))
results.extend(_search_knowledgebase(query, search_term))
results.extend(_search_employees(query, search_term))
results.extend(_search_assets(query, search_term))
results.extend(_search_measuringtools(query, search_term))
results.extend(_search_customfields(query, search_term))
results.extend(_search_notifications(query, search_term))
results.extend(_search_hostnames(query, search_term))
results.extend(_search_vendor_model_type(query, search_term))
# IP-specific searches
if classification['is_ip']:
results.extend(_search_by_ip(query, search_term))
results.extend(_search_subnets(query))
# Sort by relevance (highest first)
results.sort(key=lambda x: x['relevance'], reverse=True)
# Remove duplicates (prefer higher relevance)
seen_ids = {}
unique_results = []
for r in results:
key = (r['type'], r['id'])
if key not in seen_ids:
seen_ids[key] = True
unique_results.append(r)
# Drop result types disabled in Settings > Search (search_<type>_enabled).
# Missing key = enabled. One query, default-on.
disabled_types = {
s.key[len('search_'):-len('_enabled')]
for s in Setting.query.filter_by(category='search').all()
if s.get_typed_value() is False
}
if disabled_types:
unique_results = [r for r in unique_results if r['type'] not in disabled_types]
# Compute type counts before truncation
type_counts = {}
for r in unique_results:
t = r['type']
type_counts[t] = type_counts.get(t, 0) + 1
total_all = len(unique_results)
# Limit total results
unique_results = unique_results[:50]
# Check for smart redirect
response_data = {
'results': unique_results,
'query': query,
'total': len(unique_results),
'total_all': total_all,
'counts': type_counts,
}
redirect = _check_smart_redirect(query, classification)
if redirect:
response_data['redirect'] = redirect
return success_response(response_data)

911
shopdb/core/api/settings.py Normal file
View File

@@ -0,0 +1,911 @@
"""Settings API routes."""
import os
from flask import Blueprint, request, current_app, send_from_directory
from flask_jwt_extended import jwt_required, get_jwt_identity
from werkzeug.utils import secure_filename
from shopdb.extensions import db, cache
from shopdb.core.models import Setting, AuditLog
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_permission, require_role
settings_bp = Blueprint('settings', __name__)
# Floor-map blueprint uploads live in the instance dir and are served publicly
# (the kiosk dashboards read them without auth).
MAP_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
# Branding logo uploads allow everything a map image does plus .ico (favicons).
BRANDING_IMAGE_EXTENSIONS = MAP_IMAGE_EXTENSIONS | {'.ico'}
# Branding logo kinds and the setting key each one writes.
BRANDING_KIND_SETTINGS = {
'site': 'site_logo',
'qr': 'qr_logo',
'badge': 'badge_logo',
'favicon': 'site_favicon',
}
def _map_dir():
return os.path.join(current_app.instance_path, 'maps')
def _branding_dir():
return os.path.join(current_app.instance_path, 'branding')
# Cache key for settings
SETTINGS_CACHE_KEY = 'system_settings'
SETTINGS_CACHE_TTL = 300 # 5 minutes
# Placeholder returned in API responses for secret values so they are never
# exposed in plaintext. Sending it back on update is treated as "unchanged".
SECRET_MASK = '********'
# Public-settings allowlist. An UNAUTHENTICATED caller (kiosk dashboards, print
# pages, the login screen, the setup router) may read only these; everything
# else (smtp_host, employee_db_host, zabbix_url, servicenow URLs, ...) needs a
# valid token. Secrets stay masked in both cases. Keep in sync with the keys
# frontend/src/utils/siteSettings.js + mapConfig.js + setupState.js read before
# login. Whole categories that are purely presentation are allowed wholesale;
# the rest are named keys so a new integration key does not leak by default.
PUBLIC_SETTING_CATEGORIES = {'branding', 'map'}
PUBLIC_SETTING_KEYS = {
'site_base_url', 'facility_name', 'printer_hostname_template',
'contact_email_domain', 'servicenow_enabled', 'setup_complete',
}
def _is_public_setting(setting) -> bool:
return (setting.category in PUBLIC_SETTING_CATEGORIES
or setting.key in PUBLIC_SETTING_KEYS)
# Optional asset identifiers and the asset types they can be toggled on.
# Drives per-type seed keys and the Settings matrix UI. The asset type names
# match the AssetType.assettype values seeded by each plugin.
IDENTIFIER_LABELS = {
'gaugelabreference': 'Gauge Lab Reference',
'maintenancereference': 'Maintenance Reference',
'fqdn': 'FQDN / hostname',
}
IDENTIFIER_ASSETTYPES = ['machine', 'computer', 'printer', 'network_device',
'measuring_tool']
# Global-search result types that can be toggled on/off independently of whether
# the owning plugin is enabled. Keys match the `type` field on search results;
# seed keys are search_<type>_enabled (boolean, default true). Drives the
# Settings "Search" toggles and the filter in shopdb/core/api/search.py.
SEARCH_DOMAINS = {
'application': 'Applications',
'knowledgebase': 'Knowledge Base',
'employee': 'Employees',
'machine': 'Machines',
'computer': 'PCs',
'printer': 'Printers',
'network_device': 'Network Devices',
'measuring_tool': 'Measuring Tools',
'notification': 'Notifications',
'subnet': 'Subnets',
}
def _is_secret(key: str) -> bool:
return 'password' in key or 'token' in key or 'secret' in key
def _serialize_setting(setting):
"""Serialize a setting, masking secret values so they never leave the API."""
data = setting.to_dict()
if _is_secret(setting.key):
data['value'] = SECRET_MASK if setting.value else ''
return data
def get_cached_settings():
"""Get all settings from cache or database."""
cached = cache.get(SETTINGS_CACHE_KEY)
if cached is not None:
return cached
settings = Setting.query.all()
result = {s.key: s.get_typed_value() for s in settings}
cache.set(SETTINGS_CACHE_KEY, result, timeout=SETTINGS_CACHE_TTL)
return result
def invalidate_settings_cache():
"""Clear the settings cache."""
cache.delete(SETTINGS_CACHE_KEY)
@settings_bp.route('/map-blueprint', methods=['POST'])
@jwt_required()
@require_role('admin')
def upload_map_blueprint():
"""Upload a floor-map blueprint image and point the setting at it.
multipart/form-data: file=<image>, theme=light|dark. Saves to the instance
maps dir and sets map_blueprint_<theme> to the served URL.
"""
theme = (request.form.get('theme') or '').strip().lower()
if theme not in ('light', 'dark'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'theme must be light or dark')
upload = request.files.get('file')
if not upload or not upload.filename:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
ext = os.path.splitext(upload.filename)[1].lower()
if ext not in MAP_IMAGE_EXTENSIONS:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unsupported image type {ext}')
os.makedirs(_map_dir(), exist_ok=True)
filename = secure_filename(f'blueprint-{theme}{ext}')
upload.save(os.path.join(_map_dir(), filename))
url = f'/api/settings/map-blueprint/{filename}'
key = f'map_blueprint_{theme}'
setting = Setting.query.filter_by(key=key).first()
if setting:
setting.value = url
else:
db.session.add(Setting(key=key, value=url, valuetype='string', category='map'))
db.session.commit()
invalidate_settings_cache()
return success_response({'key': key, 'value': url}, message='Blueprint uploaded')
@settings_bp.route('/map-blueprint/<path:filename>', methods=['GET'])
def serve_map_blueprint(filename):
"""Serve an uploaded blueprint image (public - kiosks read it)."""
return send_from_directory(_map_dir(), filename)
@settings_bp.route('/branding-logo', methods=['POST'])
@jwt_required()
@require_role('admin')
def upload_branding_logo():
"""Upload a branding logo image and point the matching setting at it.
multipart/form-data: file=<image>, kind=site|qr|badge|favicon. Saves to the
instance branding dir and sets the matching branding setting to the served
URL. .ico is accepted in addition to the map image types (for favicons).
"""
kind = (request.form.get('kind') or '').strip().lower()
if kind not in BRANDING_KIND_SETTINGS:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'kind must be one of: ' + ', '.join(sorted(BRANDING_KIND_SETTINGS)))
upload = request.files.get('file')
if not upload or not upload.filename:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
ext = os.path.splitext(upload.filename)[1].lower()
if ext not in BRANDING_IMAGE_EXTENSIONS:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unsupported image type {ext}')
os.makedirs(_branding_dir(), exist_ok=True)
filename = secure_filename(f'logo-{kind}{ext}')
upload.save(os.path.join(_branding_dir(), filename))
url = f'/api/settings/branding/{filename}'
key = BRANDING_KIND_SETTINGS[kind]
setting = Setting.query.filter_by(key=key).first()
if setting:
setting.value = url
else:
db.session.add(Setting(key=key, value=url, valuetype='string', category='branding'))
db.session.commit()
invalidate_settings_cache()
return success_response({'key': key, 'value': url}, message='Logo uploaded')
@settings_bp.route('/branding/<path:filename>', methods=['GET'])
def serve_branding_logo(filename):
"""Serve an uploaded branding logo (public - kiosks/print pages read it)."""
return send_from_directory(_branding_dir(), filename)
@settings_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_settings():
"""List all settings, optionally filtered by category."""
category = request.args.get('category')
query = Setting.query
if category:
query = query.filter_by(category=category)
settings = query.order_by(Setting.category, Setting.key).all()
# Unauthenticated callers see only the public allowlist (branding + a few
# bootstrap keys); an authed principal sees everything (secrets masked).
if get_jwt_identity() is None:
settings = [s for s in settings if _is_public_setting(s)]
return success_response([_serialize_setting(s) for s in settings])
@settings_bp.route('/<key>', methods=['GET'])
@jwt_required(optional=True)
def get_setting(key: str):
"""Get a single setting by key."""
setting = Setting.query.filter_by(key=key).first()
if not setting:
return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404)
# A non-public key is invisible to an unauthenticated caller (404, not 403,
# so its existence is not confirmed either).
if get_jwt_identity() is None and not _is_public_setting(setting):
return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404)
return success_response(_serialize_setting(setting))
@settings_bp.route('/<key>', methods=['PUT'])
@jwt_required()
@require_permission('settings.edit')
def update_setting(key: str):
"""Update a setting value."""
data = request.get_json()
if data is None or 'value' not in data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'value is required')
setting = Setting.query.filter_by(key=key).first()
# Upsert: create the row on first write (e.g. plugin config keys the setup
# wizard saves). New keys default to a plugin-scoped string setting.
if not setting:
setting = Setting(key=key, value='', valuetype='string', category='plugin')
db.session.add(setting)
# Track old value for audit
old_value = setting.value
value = data['value']
# A secret submitted as the mask placeholder means "leave unchanged" - the
# client only ever received the mask, so don't overwrite the real secret.
if _is_secret(key) and value == SECRET_MASK:
return success_response(_serialize_setting(setting), message='Setting unchanged')
# Convert value to string for storage
if isinstance(value, bool):
setting.value = 'true' if value else 'false'
else:
setting.value = str(value) if value is not None else None
# Audit log (mask sensitive values)
is_sensitive = _is_secret(key)
AuditLog.log('updated', 'Setting', entityname=key, changes={
'value': {
'old': '***' if is_sensitive else old_value,
'new': '***' if is_sensitive else setting.value
}
})
db.session.commit()
invalidate_settings_cache()
return success_response(_serialize_setting(setting), message='Setting updated')
@settings_bp.route('/test-email', methods=['POST'])
@jwt_required()
@require_permission('settings.edit')
def test_email():
"""Send a test email to verify SMTP configuration.
Request: { "to": "addr@example.com" } (falls back to alert_recipients)
Returns a 200 with a `sent` flag either way. When SMTP is not configured
the response explains that gracefully; when a real send fails the SMTP error
is surfaced with any credential scrubbed out.
"""
from shopdb.utils.mailer import get_smtp_config, render_email, try_send
data = request.get_json() or {}
config = get_smtp_config()
recipient = data.get('to') or config['alert_recipients']
if not config['enabled'] or not config['host']:
return success_response(
{'sent': False, 'reason': 'notconfigured'},
message='Email is not configured (SMTP disabled or host unset).')
if not recipient:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'No recipient. Provide "to" or set Alert Recipients.')
html, text = render_email(
'ShopDB test email',
'<p>This is a test message confirming your SMTP settings work.</p>')
ok, error = try_send(recipient, 'ShopDB test email', html, text=text)
if ok:
return success_response({'sent': True}, message='Test email sent.')
return success_response(
{'sent': False, 'error': error},
message='Test email failed: ' + (error or 'unknown error'))
@settings_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('settings.edit')
def create_setting():
"""Create a new setting (admin only)."""
data = request.get_json()
if not data or not data.get('key'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'key is required')
if Setting.query.filter_by(key=data['key']).first():
return error_response(ErrorCodes.CONFLICT, f"Setting '{data['key']}' already exists", http_code=409)
value = data.get('value')
if isinstance(value, bool):
value_str = 'true' if value else 'false'
else:
value_str = str(value) if value is not None else None
setting = Setting(
key=data['key'],
value=value_str,
valuetype=data.get('valuetype', 'string'),
category=data.get('category', 'general'),
description=data.get('description')
)
db.session.add(setting)
db.session.commit()
invalidate_settings_cache()
return success_response(setting.to_dict(), message='Setting created', http_code=201)
def build_default_settings():
"""Return the full default-settings list (identifier toggles + static).
Shared by the /settings/seed route and the `flask seed settings` CLI so
the two definitions never drift.
"""
# Asset identifier feature toggles, per identifier AND per asset type.
# Key format: identifier_<name>_<assettype>_enabled (boolean). Admins pick
# which optional identifiers show on which asset types. See ADR-001.
identifierdefaults = [
{
'key': f'identifier_{name}_{assettype}_enabled',
'value': 'true',
'valuetype': 'boolean',
'category': 'identifiers',
'description': f'Show the {label} identifier on {assettype} assets',
}
for name, label in IDENTIFIER_LABELS.items()
for assettype in IDENTIFIER_ASSETTYPES
]
# Per-domain global-search toggles (search_<type>_enabled).
searchdefaults = [
{
'key': f'search_{key}_enabled',
'value': 'true',
'valuetype': 'boolean',
'category': 'search',
'description': f'Include {label} in global search results',
}
for key, label in SEARCH_DOMAINS.items()
]
# Facility floor-map blueprint. Each site instance (ADR-004) points these
# at its own floor-plan image and pixel dimensions; the map frontend reads
# them instead of hardcoding one facility's plan. Defaults are a generic
# placeholder floor plan so an un-reconfigured install still renders.
mapdefaults = [
{
'key': 'map_blueprint_light',
'value': '/static/images/floorplan-placeholder.svg',
'valuetype': 'string',
'category': 'map',
'description': 'Floor-map blueprint image (light theme) for this facility'
},
{
'key': 'map_blueprint_dark',
'value': '/static/images/floorplan-placeholder.svg',
'valuetype': 'string',
'category': 'map',
'description': 'Floor-map blueprint image (dark theme) for this facility'
},
{
'key': 'map_width',
'value': '3300',
'valuetype': 'integer',
'category': 'map',
'description': 'Floor-map blueprint width in pixels (native size of the image)'
},
{
'key': 'map_height',
'value': '2550',
'valuetype': 'integer',
'category': 'map',
'description': 'Floor-map blueprint height in pixels (native size of the image)'
},
]
# Site identity. Each instance (ADR-004) sets its own public URL - used for
# QR codes and any absolute link the app emits - and facility name shown on
# the shopfloor dashboard. Blank site_base_url falls back to the browsing
# origin so nothing breaks before a site configures it.
sitedefaults = [
{
'key': 'setup_complete',
'value': 'false',
'valuetype': 'boolean',
'category': 'site',
'description': 'Set true once the first-run setup wizard has been finished'
},
{
'key': 'employee_directory_mode',
'value': 'selfhosted',
'valuetype': 'string',
'category': 'site',
'description': "Employee directory source: 'selfhosted' (tables in this app, default) or 'external' (a separate HR database)"
},
{
'key': 'usb_directory_mode',
'value': 'selfhosted',
'valuetype': 'string',
'category': 'site',
'description': "USB check-in/out source: 'selfhosted' (tables in this app, default) or 'external' (a separate cmmc_usb database)"
},
{
'key': 'site_base_url',
'value': '',
'valuetype': 'string',
'category': 'site',
'description': 'Public base URL of this site (scheme + host), e.g. https://shopdb.example.net. Used for QR codes and absolute links. Blank = use the browsing origin.'
},
{
'key': 'facility_name',
'value': '',
'valuetype': 'string',
'category': 'site',
'description': 'Facility name shown on the shopfloor dashboard header (blank = frontend falls back to ShopDB)'
},
{
'key': 'pc_access_domain',
'value': 'device.geaerospace.net',
'valuetype': 'string',
'category': 'site',
'description': 'Domain appended to a PC hostname to build remote-access links (host.device.geaerospace.net). Blank = use the hostname as-is.'
},
{
'key': 'employeeid_pattern',
'value': r'^\d{9}$',
'valuetype': 'string',
'category': 'site',
'description': 'Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never errors.'
},
{
'key': 'dualpath_single_machine',
'value': 'true',
'valuetype': 'boolean',
'category': 'site',
'description': 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. The data model always keeps both bay records; detail pages stay per-bay with a sibling banner. Off = list and count both bays separately.'
},
{
'key': 'printer_hostname_template',
'value': 'Printer-{ip}.printer.geaerospace.net',
'valuetype': 'string',
'category': 'site',
'description': 'Template for a printer hostname built from its IP. {ip} is the dash-separated IP address.'
},
{
'key': 'contact_email_domain',
'value': 'geaerospace.com',
'valuetype': 'string',
'category': 'site',
'description': 'Email domain appended to a contact SSO to build email/Teams links (sso@domain). Blank disables contact action buttons.'
},
]
# Site branding. Each instance (ADR-004) can replace the shipped GE defaults
# with its own logos, favicon, and primary color. Blank values fall back to
# the built-in shipped assets so an un-reconfigured install still renders.
brandingdefaults = [
{
'key': 'site_logo',
'value': '/ge-aerospace-logo.svg',
'valuetype': 'string',
'category': 'branding',
'description': 'Main site logo shown in the app header and login page'
},
{
'key': 'qr_logo',
'value': '/ge-monogram.svg',
'valuetype': 'string',
'category': 'branding',
'description': 'Logo composited in the center of printer QR labels (blank = no QR overlay)'
},
{
'key': 'badge_logo',
'value': '/ge-aerospace-logo.svg',
'valuetype': 'string',
'category': 'branding',
'description': 'Logo shown on the machine badge print page'
},
{
'key': 'site_favicon',
'value': '',
'valuetype': 'string',
'category': 'branding',
'description': 'Browser tab favicon (blank = shipped /favicon.svg)'
},
{
'key': 'brand_primary_color',
'value': '',
'valuetype': 'string',
'category': 'branding',
'description': 'Primary brand color as a CSS color value (blank = built-in theme color)'
},
{
'key': 'brand_primary_dark_color',
'value': '',
'valuetype': 'string',
'category': 'branding',
'description': 'Primary hover/active color (blank = derived by darkening the primary color ~15%)'
},
{
'key': 'brand_accent_color',
'value': '',
'valuetype': 'string',
'category': 'branding',
'description': 'Accent color for secondary buttons and badges (blank = built-in theme color)'
},
{
'key': 'brand_sidebar_color',
'value': '',
'valuetype': 'string',
'category': 'branding',
'description': 'Sidebar background color (blank = built-in theme color)'
},
]
# Printed QR/label targets. Blank template = QR links to the asset's own
# detail page on this instance; a non-blank value is a URL template with
# {placeholder} substitution so a site can point labels anywhere.
printingdefaults = [
{
'key': 'qr_target_printer',
'value': '',
'valuetype': 'string',
'category': 'printing',
'description': 'Custom URL template for printer QR labels. Blank = link to the printer page. Placeholders: {printerid}, {assetid}, {assetnumber}, {serialnumber}, {ip}, {hostname}.'
},
{
'key': 'qr_target_usb',
'value': '',
'valuetype': 'string',
'category': 'printing',
'description': 'Custom URL template for USB label QR codes. Blank = link to the USB device page. Placeholders: {id}, {serialnumber}, {alias}.'
},
{
'key': 'usb_label_style',
'value': 'barcode',
'valuetype': 'string',
'category': 'printing',
'description': "USB mini-label code style: 'barcode' (CODE128 of the serial number) or 'qr' (QR code linking to the QR target)."
},
{
'key': 'qr_target_machine',
'value': '',
'valuetype': 'string',
'category': 'printing',
'description': 'Custom URL template for machine labels. Blank = link to the machine page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.'
},
{
'key': 'qr_target_computer',
'value': '',
'valuetype': 'string',
'category': 'printing',
'description': 'Custom URL template for computer labels. Blank = link to the computer page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.'
},
{
'key': 'qr_target_network_device',
'value': '',
'valuetype': 'string',
'category': 'printing',
'description': 'Custom URL template for network-device labels. Blank = link to the device page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.'
},
{
'key': 'qr_target_measuring_tool',
'value': '',
'valuetype': 'string',
'category': 'printing',
'description': 'Custom URL template for measuring-tool labels. Blank = link to the tool page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}, {locationcode}, {locationname}.'
},
{
'key': 'label_default_style',
'value': 'card',
'valuetype': 'string',
'category': 'printing',
'description': "Default asset-label layout: 'card' (badge with image and identity) or 'plain' (just the code and a caption)."
},
{
'key': 'label_default_codetype',
'value': 'qr',
'valuetype': 'string',
'category': 'printing',
'description': "Default asset-label code type: 'qr' (QR code) or 'barcode' (CODE128)."
},
]
# Per-asset-type default for what a label's code encodes. Machines default
# to their machine number (assetnumber), measuring tools to their inspection
# location code, everything else to a link to the asset page. Values:
# assetpage | assetnumber | serialnumber | location | custom.
labelencodesdefaults = {
'machine': 'assetnumber',
'computer': 'assetpage',
'printer': 'assetpage',
'network_device': 'assetpage',
'measuring_tool': 'location',
}
printingdefaults += [
{
'key': f'label_default_encodes_{assettype}',
'value': value,
'valuetype': 'string',
'category': 'printing',
'description': f'What a {assettype} label encodes by default: assetpage, '
'assetnumber, serialnumber'
+ (', location' if assettype == 'measuring_tool' else '')
+ ', or custom.',
}
for assettype, value in labelencodesdefaults.items()
]
# Collector pc-type -> ComputerType mapping is computers-plugin domain;
# the plugin seeds pctypemap_<pxetype> settings on install.
defaults = sitedefaults + brandingdefaults + printingdefaults + identifierdefaults + searchdefaults + mapdefaults + [
# ServiceNow ticket links. Each instance points these at its own
# ServiceNow tenant; blank/disabled renders tickets as plain text.
{
'key': 'servicenow_enabled',
'value': 'true',
'valuetype': 'boolean',
'category': 'integrations',
'description': 'Enable ServiceNow ticket recognition and links in search and dashboards'
},
{
'key': 'servicenow_search_url',
'value': (
'https://geaerospaceqa.service-now.com/now/nav/ui/search/'
'0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/'
'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
),
'valuetype': 'string',
'category': 'integrations',
'description': 'ServiceNow global-search URL template. {ticket} is the ticket number.'
},
{
'key': 'servicenow_ticket_prefixes',
'value': 'GEINC,GECHG,GERIT,GESCT',
'valuetype': 'string',
'category': 'integrations',
'description': 'Comma-separated ticket-number prefixes recognized as ServiceNow tickets'
},
{
'key': 'servicenow_incident_url',
'value': 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/',
'valuetype': 'string',
'category': 'integrations',
'description': 'ServiceNow incident URL template. {ticket} is the incident number.'
},
{
'key': 'servicenow_change_url',
'value': 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/',
'valuetype': 'string',
'category': 'integrations',
'description': 'ServiceNow change-request URL template. {ticket} is the change number.'
},
# Zabbix integration
{
'key': 'zabbix_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'integrations',
'description': 'Enable Zabbix integration for printer supply monitoring'
},
{
'key': 'zabbix_url',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'Zabbix API URL (e.g., http://zabbix.example.com:8080)'
},
{
'key': 'zabbix_token',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'Zabbix API authentication token'
},
# Dell warranty lookup (Dell TechDirect Warranty API, OAuth2)
{
'key': 'warranty_dell_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'integrations',
'description': 'Enable Dell warranty lookups (service-tag entitlements)'
},
{
'key': 'warranty_dell_clientid',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'Dell TechDirect API client id'
},
{
'key': 'warranty_dell_clientsecret',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'Dell TechDirect API client secret'
},
{
'key': 'warranty_dell_tokenurl',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'Dell OAuth token URL (blank = Dell default)'
},
{
'key': 'warranty_dell_apiurl',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'Dell warranty API URL (blank = Dell default)'
},
# Email/SMTP settings
{
'key': 'smtp_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'email',
'description': 'Enable email notifications and alerts'
},
{
'key': 'smtp_host',
'value': '',
'valuetype': 'string',
'category': 'email',
'description': 'SMTP server hostname'
},
{
'key': 'smtp_port',
'value': '587',
'valuetype': 'integer',
'category': 'email',
'description': 'SMTP server port (usually 587 for TLS, 465 for SSL, 25 for unencrypted)'
},
{
'key': 'smtp_username',
'value': '',
'valuetype': 'string',
'category': 'email',
'description': 'SMTP authentication username'
},
{
'key': 'smtp_password',
'value': '',
'valuetype': 'string',
'category': 'email',
'description': 'SMTP authentication password'
},
{
'key': 'smtp_use_tls',
'value': 'true',
'valuetype': 'boolean',
'category': 'email',
'description': 'Use TLS encryption for SMTP connection'
},
{
'key': 'smtp_from_address',
'value': '',
'valuetype': 'string',
'category': 'email',
'description': 'From address for outgoing emails'
},
{
'key': 'smtp_from_name',
'value': 'ShopDB',
'valuetype': 'string',
'category': 'email',
'description': 'From name for outgoing emails'
},
{
'key': 'alert_recipients',
'value': '',
'valuetype': 'string',
'category': 'email',
'description': 'Default email recipients for alerts (comma-separated)'
},
# Audit log settings
{
'key': 'audit_retention_days',
'value': '90',
'valuetype': 'integer',
'category': 'audit',
'description': 'Number of days to retain audit logs (0 = keep forever)'
},
# Authentication settings
{
'key': 'saml_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'auth',
'description': 'Enable SAML SSO authentication'
},
{
'key': 'saml_idp_metadata_url',
'value': '',
'valuetype': 'string',
'category': 'auth',
'description': 'SAML Identity Provider metadata URL'
},
{
'key': 'saml_entity_id',
'value': '',
'valuetype': 'string',
'category': 'auth',
'description': 'SAML Service Provider entity ID (e.g., https://shopdb.example.com)'
},
{
'key': 'saml_acs_url',
'value': '',
'valuetype': 'string',
'category': 'auth',
'description': 'SAML Assertion Consumer Service URL'
},
{
'key': 'saml_allow_local_login',
'value': 'true',
'valuetype': 'boolean',
'category': 'auth',
'description': 'Allow local username/password login when SAML is enabled'
},
{
'key': 'saml_auto_create_users',
'value': 'true',
'valuetype': 'boolean',
'category': 'auth',
'description': 'Automatically create users on first SAML login'
},
{
'key': 'saml_admin_group',
'value': '',
'valuetype': 'string',
'category': 'auth',
'description': 'SAML group name that grants admin role'
},
]
return defaults
@settings_bp.route('/seed', methods=['POST'])
@jwt_required()
@require_permission('settings.edit')
def seed_default_settings():
"""Seed default settings if they don't exist."""
created = 0
for d in build_default_settings():
if not Setting.query.filter_by(key=d['key']).first():
setting = Setting(**d)
db.session.add(setting)
created += 1
db.session.commit()
invalidate_settings_cache()
return success_response({'created': created}, message=f'{created} default settings created')

114
shopdb/core/api/setup.py Normal file
View File

@@ -0,0 +1,114 @@
"""First-run setup wizard support endpoints.
The wizard itself is frontend; these endpoints cover the pieces that need
server work: seeding common starter data and marking setup finished. Site,
plugin, and map configuration reuse the existing settings / plugins APIs.
"""
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from werkzeug.security import generate_password_hash
from shopdb.extensions import db
from shopdb.core.models import Vendor, Setting, User, Role
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
setup_bp = Blueprint('setup', __name__)
# Common hardware vendors most sites will want on hand.
STARTER_VENDORS = [
('Dell Inc.', 'https://www.dell.com'),
('HP Inc.', 'https://www.hp.com'),
('Lenovo', 'https://www.lenovo.com'),
('Xerox', 'https://www.xerox.com'),
('Zebra Technologies', 'https://www.zebra.com'),
('Cisco', 'https://www.cisco.com'),
('Brother', 'https://www.brother.com'),
('Microsoft', 'https://www.microsoft.com'),
]
@setup_bp.route('/needs-admin', methods=['GET'])
def needs_admin():
"""True when no users exist yet - the app's very first run. Public so the
login screen can offer to create the first admin."""
return success_response({'needsadmin': User.query.count() == 0})
@setup_bp.route('/create-admin', methods=['POST'])
def create_admin():
"""Create the first admin. Only works while there are zero users, so it
can't be abused after setup. No auth (there is no one to auth as yet)."""
if User.query.count() > 0:
return error_response(ErrorCodes.FORBIDDEN,
'An account already exists; sign in instead.', http_code=403)
data = request.get_json() or {}
username = (data.get('username') or '').strip()
email = (data.get('email') or '').strip()
password = data.get('password') or ''
if not (username and email and password):
return error_response(ErrorCodes.VALIDATION_ERROR,
'username, email and password are required')
admin_role = Role.query.filter_by(rolename='admin').first()
if not admin_role:
admin_role = Role(rolename='admin', description='Administrator')
db.session.add(admin_role)
user = User(username=username, email=email,
passwordhash=generate_password_hash(password), isactive=True)
user.roles.append(admin_role)
db.session.add(user)
db.session.commit()
return success_response({'username': username}, message='Admin created', http_code=201)
@setup_bp.route('/seed-reference', methods=['POST'])
@jwt_required()
@require_role('admin')
def seed_reference():
"""Seed core reference data + permissions + default settings. Idempotent -
reuses the same routines as the `flask seed ...` CLI commands."""
# Invoke the seed CLI commands in a proper click context via the runner.
runner = current_app.test_cli_runner()
for command in ('reference-data', 'permissions', 'settings'):
result = runner.invoke(args=['seed', command])
if result.exit_code not in (0, None):
return error_response(ErrorCodes.INTERNAL_ERROR,
f'seed {command} failed: {result.output}', http_code=500)
return success_response(message='Reference data, permissions and settings seeded.')
@setup_bp.route('/seed-starter', methods=['POST'])
@jwt_required()
@require_role('admin')
def seed_starter():
"""Add common vendors that are not already present. Idempotent."""
existing = {v.vendor.strip().lower() for v in Vendor.query.all()}
added = []
for name, website in STARTER_VENDORS:
if name.strip().lower() in existing:
continue
db.session.add(Vendor(vendor=name, website=website))
added.append(name)
db.session.commit()
return success_response(
{'added': added, 'addedcount': len(added)},
message=f'Seeded {len(added)} vendor(s).'
)
@setup_bp.route('/complete', methods=['POST'])
@jwt_required()
@require_role('admin')
def mark_complete():
"""Flag the first-run setup as finished."""
row = Setting.query.filter_by(key='setup_complete').first()
if row:
row.value = 'true'
else:
db.session.add(Setting(key='setup_complete', value='true',
valuetype='boolean', category='site'))
db.session.commit()
return success_response({'complete': True}, message='Setup marked complete.')

View File

@@ -0,0 +1,219 @@
"""Support Teams API endpoints - teams and their nested contacts."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import (
SupportTeam, SupportTeamContact, Application, AuditLog
)
from shopdb.utils.responses import (
success_response,
error_response,
ErrorCodes
)
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
supportteams_bp = Blueprint('supportteams', __name__)
@supportteams_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_support_teams():
"""List support teams (with contacts). ?active and ?teamname filters."""
query = SupportTeam.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(SupportTeam.isactive == True)
# Exact-match natural-key lookup for idempotent import (team name).
if exactteamname := request.args.get('teamname'):
query = query.filter(SupportTeam.teamname == exactteamname)
teams = query.order_by(SupportTeam.teamname).all()
return success_response([team.to_dict() for team in teams])
@supportteams_bp.route('/<int:team_id>', methods=['GET'])
@jwt_required(optional=True)
def get_support_team(team_id: int):
"""Get a single support team with its contacts."""
team = db.session.get(SupportTeam, team_id)
if not team:
return error_response(ErrorCodes.NOT_FOUND, 'Support team not found',
http_code=404)
return success_response(team.to_dict())
@supportteams_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
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')
if SupportTeam.query.filter_by(teamname=data['teamname']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Support team '{data['teamname']}' already exists",
http_code=409)
team = SupportTeam(
teamname=data['teamname'],
teamurl=data.get('teamurl'),
isactive=data.get('isactive', True))
db.session.add(team)
apply_import_timestamps(team, data)
db.session.flush()
AuditLog.log('created', 'SupportTeam', entityid=team.supportteamid,
entityname=team.teamname)
db.session.commit()
return success_response(team.to_dict(), message='Support team created',
http_code=201)
@supportteams_bp.route('/<int:team_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_support_team(team_id: int):
"""Update a support team."""
team = db.session.get(SupportTeam, team_id)
if not team:
return error_response(ErrorCodes.NOT_FOUND, 'Support team not found',
http_code=404)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if 'teamname' in data and data['teamname'] != team.teamname:
if SupportTeam.query.filter_by(teamname=data['teamname']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Support team '{data['teamname']}' already exists",
http_code=409)
for key in ['teamname', 'teamurl', 'isactive']:
if key in data:
setattr(team, key, data[key])
apply_import_timestamps(team, data)
AuditLog.log('updated', 'SupportTeam', entityid=team.supportteamid,
entityname=team.teamname)
db.session.commit()
return success_response(team.to_dict(), message='Support team updated')
@supportteams_bp.route('/<int:team_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_support_team(team_id: int):
"""Delete a support team; 409 while any application references it."""
team = db.session.get(SupportTeam, team_id)
if not team:
return error_response(ErrorCodes.NOT_FOUND, 'Support team not found',
http_code=404)
refcount = Application.query.filter_by(supportteamid=team_id).count()
if refcount:
return error_response(
ErrorCodes.CONFLICT,
f'{refcount} application(s) still reference this support team',
http_code=409)
AuditLog.log('deleted', 'SupportTeam', entityid=team.supportteamid,
entityname=team.teamname)
db.session.delete(team) # cascade removes its contacts
db.session.commit()
return success_response(message='Support team deleted')
# ---- Contacts (nested under a team) ----
@supportteams_bp.route('/<int:team_id>/contacts', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_contact(team_id: int):
"""Add a contact to a support team."""
team = db.session.get(SupportTeam, team_id)
if not team:
return error_response(ErrorCodes.NOT_FOUND, 'Support team not found',
http_code=404)
data = request.get_json()
if not data or not data.get('name'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
contact = SupportTeamContact(
supportteamid=team_id,
name=data['name'],
sso=data.get('sso'),
sortorder=data.get('sortorder', 0),
isactive=data.get('isactive', True))
db.session.add(contact)
apply_import_timestamps(contact, data)
db.session.flush()
AuditLog.log('created', 'SupportTeamContact', entityid=contact.contactid,
entityname=contact.name)
db.session.commit()
return success_response(contact.to_dict(), message='Contact created',
http_code=201)
@supportteams_bp.route('/<int:team_id>/contacts/<int:contact_id>',
methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_contact(team_id: int, contact_id: int):
"""Update a support-team contact."""
contact = SupportTeamContact.query.filter_by(
contactid=contact_id, supportteamid=team_id).first()
if not contact:
return error_response(ErrorCodes.NOT_FOUND, 'Contact not found',
http_code=404)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
for key in ['name', 'sso', 'sortorder', 'isactive']:
if key in data:
setattr(contact, key, data[key])
apply_import_timestamps(contact, data)
AuditLog.log('updated', 'SupportTeamContact', entityid=contact.contactid,
entityname=contact.name)
db.session.commit()
return success_response(contact.to_dict(), message='Contact updated')
@supportteams_bp.route('/<int:team_id>/contacts/<int:contact_id>',
methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_contact(team_id: int, contact_id: int):
"""Delete a support-team contact."""
contact = SupportTeamContact.query.filter_by(
contactid=contact_id, supportteamid=team_id).first()
if not contact:
return error_response(ErrorCodes.NOT_FOUND, 'Contact not found',
http_code=404)
AuditLog.log('deleted', 'SupportTeamContact', entityid=contact.contactid,
entityname=contact.name)
db.session.delete(contact)
db.session.commit()
return success_response(message='Contact deleted')

389
shopdb/core/api/users.py Normal file
View File

@@ -0,0 +1,389 @@
"""User management API routes."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, current_user
from werkzeug.security import generate_password_hash
from shopdb.extensions import db
from shopdb.core.models import (
User, Role, Permission, AuditLog, full_permission_catalog)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
users_bp = Blueprint('users', __name__)
@users_bp.route('', methods=['GET'])
@jwt_required()
@require_role('admin')
def list_users():
"""List all users."""
users = User.query.order_by(User.username).all()
return success_response([user_to_dict(u) for u in users])
@users_bp.route('/<int:userid>', methods=['GET'])
@jwt_required()
def get_user(userid: int):
"""Get a single user."""
# inline: decorators cannot express admin-or-self
if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
return success_response(user_to_dict(user))
@users_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_user():
"""Create a new user."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
# Validate required fields
if not data.get('username'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Username is required')
if not data.get('email'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Email is required')
if not data.get('password'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Password is required')
# Check uniqueness
if User.query.filter_by(username=data['username']).first():
return error_response(ErrorCodes.CONFLICT, 'Username already exists', http_code=409)
if User.query.filter_by(email=data['email']).first():
return error_response(ErrorCodes.CONFLICT, 'Email already exists', http_code=409)
# Admin-created accounts are forced to change the password on first login
# unless the admin explicitly opts out.
mustchange = data.get('mustchangepassword', True)
user = User(
username=data['username'],
email=data['email'],
passwordhash=generate_password_hash(data['password']),
firstname=data.get('firstname'),
lastname=data.get('lastname'),
isactive=data.get('isactive', True),
mustchangepassword=bool(mustchange)
)
# Assign roles
role_ids = data.get('roles', [])
if role_ids:
roles = Role.query.filter(Role.roleid.in_(role_ids)).all()
user.roles = roles
db.session.add(user)
# Audit log
AuditLog.log('created', 'User', entityname=user.username)
db.session.commit()
# Best-effort welcome email. The account exists regardless of mail outcome;
# a failure is surfaced as a warning in the response, never a hard error.
warning = None
if data.get('sendwelcome', True) and user.email:
sent = _send_welcome_email(user, data['password'])
if not sent:
warning = 'User created but the welcome email could not be sent.'
payload = user_to_dict(user)
if warning:
payload['warning'] = warning
return success_response(payload, message='User created', http_code=201)
def _send_welcome_email(user, temp_password):
"""Send a new-user welcome email with sign-in details. Returns True on send.
Best-effort: any failure (including email being disabled) returns False so
the caller can surface a soft warning without failing user creation.
"""
from shopdb.core.api.settings import get_cached_settings
from shopdb.utils.mailer import render_email, send_email
settings = get_cached_settings() or {}
facility = settings.get('facility_name') or 'ShopDB'
base_url = (settings.get('site_base_url') or '').rstrip('/')
login_link = f'{base_url}/login' if base_url else 'the ShopDB sign-in page'
body = (
f'<p>An account has been created for you at <strong>{facility}</strong>.</p>'
'<table style="border-collapse:collapse;font-size:14px;margin:12px 0;">'
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Username</td>'
f'<td><strong>{user.username}</strong></td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Temporary password</td>'
f'<td><code>{temp_password}</code></td></tr>'
'</table>'
f'<p>Sign in at {login_link}. You will be asked to set a new password '
'the first time you log in.</p>'
)
html, text = render_email(f'Welcome to {facility}', body)
return send_email(user.email, f'Your {facility} account', html, text=text)
@users_bp.route('/<int:userid>', methods=['PUT'])
@jwt_required()
def update_user(userid: int):
"""Update a user."""
# inline: decorators cannot express admin-or-self
if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
changes = {}
# Update fields
if 'email' in data and data['email'] != user.email:
if User.query.filter(User.email == data['email'], User.userid != userid).first():
return error_response(ErrorCodes.CONFLICT, 'Email already in use', http_code=409)
changes['email'] = {'old': user.email, 'new': data['email']}
user.email = data['email']
if 'firstname' in data:
if data['firstname'] != user.firstname:
changes['firstname'] = {'old': user.firstname, 'new': data['firstname']}
user.firstname = data['firstname']
if 'lastname' in data:
if data['lastname'] != user.lastname:
changes['lastname'] = {'old': user.lastname, 'new': data['lastname']}
user.lastname = data['lastname']
# Admin-only fields (inline: gates a subset of fields on a shared route)
if current_user.hasrole('admin'):
if 'isactive' in data:
if data['isactive'] != user.isactive:
changes['isactive'] = {'old': user.isactive, 'new': data['isactive']}
user.isactive = data['isactive']
if 'roles' in data:
old_roles = [r.rolename for r in user.roles]
roles = Role.query.filter(Role.roleid.in_(data['roles'])).all()
new_roles = [r.rolename for r in roles]
if set(old_roles) != set(new_roles):
changes['roles'] = {'old': old_roles, 'new': new_roles}
user.roles = roles
# Unlock user
if data.get('unlock'):
user.lockeduntil = None
user.failedlogins = 0
changes['unlocked'] = {'old': True, 'new': False}
# Password change
if 'password' in data and data['password']:
user.passwordhash = generate_password_hash(data['password'])
changes['password'] = {'old': '***', 'new': '***'}
if changes:
AuditLog.log('updated', 'User', entityid=user.userid, entityname=user.username, changes=changes)
db.session.commit()
return success_response(user_to_dict(user), message='User updated')
@users_bp.route('/<int:userid>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_user(userid: int):
"""Delete a user."""
if current_user.userid == userid:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete your own account')
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
username = user.username
db.session.delete(user)
AuditLog.log('deleted', 'User', entityid=userid, entityname=username)
db.session.commit()
return success_response(None, message='User deleted')
# Permissions endpoints
@users_bp.route('/permissions', methods=['GET'])
@jwt_required()
def list_permissions():
"""List assignable permissions grouped by category.
Driven by full_permission_catalog() (core plus ENABLED plugins) so a
disabled plugin's permissions drop out of the role grid. Roles assign by
name; the permissionid comes from the seeded Permission row when present.
"""
idbyname = {p.name: p.permissionid for p in Permission.query.all()}
catalog = full_permission_catalog()
catalog.sort(key=lambda e: (e[2], e[0]))
grouped = {}
flat = []
for name, description, category in catalog:
entry = {
'permissionid': idbyname.get(name),
'name': name,
'description': description,
}
grouped.setdefault(category, []).append(entry)
flat.append({**entry, 'category': category})
return success_response({'permissions': flat, 'grouped': grouped})
# Roles endpoints
@users_bp.route('/roles', methods=['GET'])
@jwt_required()
def list_roles():
"""List all roles with their permissions."""
roles = Role.query.order_by(Role.rolename).all()
return success_response([{
'roleid': r.roleid,
'rolename': r.rolename,
'description': r.description,
'usercount': r.users.count(),
'permissions': [p.name for p in r.permissions],
'isadmin': r.rolename == 'admin'
} for r in roles])
@users_bp.route('/roles', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_role():
"""Create a new role."""
data = request.get_json()
if not data or not data.get('rolename'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Role name is required')
if Role.query.filter_by(rolename=data['rolename']).first():
return error_response(ErrorCodes.CONFLICT, 'Role already exists', http_code=409)
role = Role(
rolename=data['rolename'],
description=data.get('description')
)
# Assign permissions
if 'permissions' in data:
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
role.permissions = perms
db.session.add(role)
AuditLog.log('created', 'Role', entityname=role.rolename)
db.session.commit()
return success_response({
'roleid': role.roleid,
'rolename': role.rolename,
'description': role.description,
'permissions': [p.name for p in role.permissions]
}, message='Role created', http_code=201)
@users_bp.route('/roles/<int:roleid>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_role(roleid: int):
"""Update a role."""
role = db.session.get(Role, roleid)
if not role:
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
# Cannot modify admin role permissions
if role.rolename == 'admin' and 'permissions' in request.get_json():
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot modify admin role permissions')
data = request.get_json()
changes = {}
if 'description' in data:
if data['description'] != role.description:
changes['description'] = {'old': role.description, 'new': data['description']}
role.description = data['description']
# Update permissions
if 'permissions' in data and role.rolename != 'admin':
old_perms = [p.name for p in role.permissions]
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
new_perms = [p.name for p in perms]
if set(old_perms) != set(new_perms):
changes['permissions'] = {'old': old_perms, 'new': new_perms}
role.permissions = perms
if changes:
AuditLog.log('updated', 'Role', entityid=role.roleid, entityname=role.rolename, changes=changes)
db.session.commit()
return success_response({
'roleid': role.roleid,
'rolename': role.rolename,
'description': role.description,
'permissions': [p.name for p in role.permissions]
}, message='Role updated')
@users_bp.route('/roles/<int:roleid>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_role(roleid: int):
"""Delete a role."""
role = db.session.get(Role, roleid)
if not role:
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
if role.rolename == 'admin':
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete the admin role')
if role.users.count() > 0:
return error_response(ErrorCodes.VALIDATION_ERROR, f'Role is assigned to {role.users.count()} user(s)')
rolename = role.rolename
db.session.delete(role)
AuditLog.log('deleted', 'Role', entityid=roleid, entityname=rolename)
db.session.commit()
return success_response(None, message='Role deleted')
def user_to_dict(user: User) -> dict:
"""Convert user to dict for API response."""
return {
'userid': user.userid,
'username': user.username,
'email': user.email,
'firstname': user.firstname,
'lastname': user.lastname,
'isactive': user.isactive,
'islocked': user.islocked,
'mustchangepassword': bool(user.mustchangepassword),
'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None,
'failedlogins': user.failedlogins,
'roles': [{'roleid': r.roleid, 'rolename': r.rolename} for r in user.roles],
'createddate': user.createddate.isoformat() + 'Z' if user.createddate else None,
'modifieddate': user.modifieddate.isoformat() + 'Z' if user.modifieddate else None
}

149
shopdb/core/api/vendors.py Normal file
View File

@@ -0,0 +1,149 @@
"""Vendors API endpoints - Full CRUD."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Vendor
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.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
vendors_bp = Blueprint('vendors', __name__)
@vendors_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_vendors():
"""List all vendors."""
page, per_page = get_pagination_params(request)
query = Vendor.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Vendor.isactive == True)
# Exact-match natural-key lookup for idempotent import (vendor name).
if exactvendor := request.args.get('vendor'):
query = query.filter(Vendor.vendor == exactvendor)
if search := request.args.get('search'):
query = query.filter(Vendor.vendor.ilike(f'%{search}%'))
query = query.order_by(Vendor.vendor)
items, total = paginate_query(query, page, per_page)
data = [v.to_dict() for v in items]
return paginated_response(data, page, per_page, total)
@vendors_bp.route('/<int:vendor_id>', methods=['GET'])
@jwt_required(optional=True)
def get_vendor(vendor_id: int):
"""Get a single vendor."""
v = db.session.get(Vendor, vendor_id)
if not v:
return error_response(
ErrorCodes.NOT_FOUND,
f'Vendor with ID {vendor_id} not found',
http_code=404
)
return success_response(v.to_dict())
@vendors_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_vendor():
"""Create a new vendor."""
data = request.get_json()
if not data or not data.get('vendor'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'vendor is required')
if Vendor.query.filter_by(vendor=data['vendor']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Vendor '{data['vendor']}' already exists",
http_code=409
)
v = Vendor(
vendor=data['vendor'],
description=data.get('description'),
website=data.get('website'),
supportphone=data.get('supportphone'),
supportemail=data.get('supportemail'),
notes=data.get('notes')
)
db.session.add(v)
apply_import_timestamps(v, data)
db.session.commit()
return success_response(v.to_dict(), message='Vendor created', http_code=201)
@vendors_bp.route('/<int:vendor_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_vendor(vendor_id: int):
"""Update a vendor."""
v = db.session.get(Vendor, vendor_id)
if not v:
return error_response(
ErrorCodes.NOT_FOUND,
f'Vendor with ID {vendor_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if 'vendor' in data and data['vendor'] != v.vendor:
if Vendor.query.filter_by(vendor=data['vendor']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Vendor '{data['vendor']}' already exists",
http_code=409
)
for key in ['vendor', 'description', 'website', 'supportphone', 'supportemail', 'notes', 'isactive']:
if key in data:
setattr(v, key, data[key])
apply_import_timestamps(v, data)
db.session.commit()
return success_response(v.to_dict(), message='Vendor updated')
@vendors_bp.route('/<int:vendor_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_vendor(vendor_id: int):
"""Delete (deactivate) a vendor."""
v = db.session.get(Vendor, vendor_id)
if not v:
return error_response(
ErrorCodes.NOT_FOUND,
f'Vendor with ID {vendor_id} not found',
http_code=404
)
v.isactive = False
db.session.commit()
return success_response(message='Vendor deleted')