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:
51
shopdb/core/api/__init__.py
Normal file
51
shopdb/core/api/__init__.py
Normal 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',
|
||||
]
|
||||
171
shopdb/core/api/apitokens.py
Normal file
171
shopdb/core/api/apitokens.py
Normal 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')
|
||||
462
shopdb/core/api/applications.py
Normal file
462
shopdb/core/api/applications.py
Normal 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
1170
shopdb/core/api/assets.py
Normal file
File diff suppressed because it is too large
Load Diff
196
shopdb/core/api/auditlogs.py
Normal file
196
shopdb/core/api/auditlogs.py
Normal 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
258
shopdb/core/api/auth.py
Normal 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')
|
||||
156
shopdb/core/api/businessunits.py
Normal file
156
shopdb/core/api/businessunits.py
Normal 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')
|
||||
486
shopdb/core/api/collector.py
Normal file
486
shopdb/core/api/collector.py
Normal 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'
|
||||
]
|
||||
})
|
||||
218
shopdb/core/api/customfields.py
Normal file
218
shopdb/core/api/customfields.py
Normal 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')
|
||||
216
shopdb/core/api/dashboard.py
Normal file
216
shopdb/core/api/dashboard.py
Normal 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__
|
||||
})
|
||||
131
shopdb/core/api/dashboarddefaults.py
Normal file
131
shopdb/core/api/dashboarddefaults.py
Normal 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')
|
||||
241
shopdb/core/api/locations.py
Normal file
241
shopdb/core/api/locations.py
Normal 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
262
shopdb/core/api/models.py
Normal 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')
|
||||
164
shopdb/core/api/modeltypes.py
Normal file
164
shopdb/core/api/modeltypes.py
Normal 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')
|
||||
146
shopdb/core/api/operatingsystems.py
Normal file
146
shopdb/core/api/operatingsystems.py
Normal 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')
|
||||
74
shopdb/core/api/plugins.py
Normal file
74
shopdb/core/api/plugins.py
Normal 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
108
shopdb/core/api/pluginui.py
Normal 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
636
shopdb/core/api/reports.py
Normal 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
912
shopdb/core/api/search.py
Normal 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
911
shopdb/core/api/settings.py
Normal 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
114
shopdb/core/api/setup.py
Normal 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.')
|
||||
219
shopdb/core/api/supportteams.py
Normal file
219
shopdb/core/api/supportteams.py
Normal 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
389
shopdb/core/api/users.py
Normal 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
149
shopdb/core/api/vendors.py
Normal 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')
|
||||
Reference in New Issue
Block a user