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:
260
shopdb/__init__.py
Normal file
260
shopdb/__init__.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""Flask application factory."""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from flask import Flask, send_from_directory
|
||||
|
||||
from .config import config
|
||||
from .extensions import db, jwt, init_extensions
|
||||
from .plugins import plugin_manager
|
||||
|
||||
# Platform contract version. See ADR-001 for the contract surface and
|
||||
# ADR-002 for the bump rules. Plugins declare a compatible range in
|
||||
# their manifest.json `core_version` field. Pre-1.0 (0.x) means the
|
||||
# contract is still settling; sister sites should pin tight ranges.
|
||||
# 0.3.0: shopdb.api expanded to the full plugin import surface (db, cache,
|
||||
# model bases, core models, response + pagination helpers, employee_connection)
|
||||
# so plugins no longer import internal core paths. Additive, hence minor bump.
|
||||
# 0.4.0: removed the never-implemented get_searchable_fields hook (search is a
|
||||
# core concern over the asset model) and wired the get_dashboard_widgets hook to
|
||||
# a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction.
|
||||
# 0.6.0: added the get_reports hook, consumed by GET /api/reports to merge
|
||||
# plugin report cards into the Reports hub. Additive optional hook, minor bump.
|
||||
# 0.7.0: added the four ADR-010 frontend-contribution hooks (get_settings_cards,
|
||||
# get_asset_panels, get_map_overlays, get_asset_presentation), consumed by the
|
||||
# GET /api/pluginui/* endpoints. Four additive optional hooks, one minor bump.
|
||||
# 0.9.0: added the dualpath single-machine collapse helpers to shopdb.api
|
||||
# (resolve_dualpath_pairs, dualpath_single_machine_enabled), consumed by the
|
||||
# machines plugin list/detail to collapse dual-bay pairs. Two additive names,
|
||||
# minor bump.
|
||||
# 0.10.0: added the get_permissions hook so a plugin declares the RBAC
|
||||
# permissions its own routes enforce, instead of core accumulating them in one
|
||||
# catalog. Consumed by full_permission_catalog() (core + enabled plugins),
|
||||
# which backs seeding, the role grid, and API-token scope validation. Additive
|
||||
# optional hook, minor bump.
|
||||
# 0.11.0: added service_token_authorized(scope) to shopdb.api so a plugin's
|
||||
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
|
||||
# managed service token without importing core token internals. Additive name
|
||||
# on the import surface, minor bump.
|
||||
__contract_version__ = '0.11.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
# bump rules. Not part of the shopdb.api contract surface, so it is
|
||||
# not re-exported there.
|
||||
__version__ = '0.7.0'
|
||||
|
||||
|
||||
def create_app(config_name: str = None) -> Flask:
|
||||
"""
|
||||
Application factory.
|
||||
|
||||
Args:
|
||||
config_name: Configuration name ('development', 'production', 'testing')
|
||||
|
||||
Returns:
|
||||
Configured Flask application
|
||||
"""
|
||||
if config_name is None:
|
||||
config_name = os.environ.get('FLASK_ENV', 'development')
|
||||
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
|
||||
config_class = config.get(config_name, config['default'])
|
||||
|
||||
# Production must validate its env-driven config before boot.
|
||||
if config_name == 'production' and hasattr(config_class, 'validate'):
|
||||
config_class.validate()
|
||||
|
||||
app.config.from_object(config_class)
|
||||
|
||||
# Load instance config if exists
|
||||
app.config.from_pyfile('config.py', silent=True)
|
||||
|
||||
# Per-plugin collector keys (ADR-006) are dynamic env-vars
|
||||
# (COLLECTOR_API_KEY_<PLUGINNAME>) that from_object cannot pick up because
|
||||
# they are not class attributes. Copy them in explicitly so per-plugin
|
||||
# credential isolation works in real deploys, not just tests.
|
||||
for envname, envvalue in os.environ.items():
|
||||
if envname.startswith('COLLECTOR_API_KEY_') and envvalue:
|
||||
app.config[envname] = envvalue
|
||||
|
||||
# Ensure instance folder exists
|
||||
os.makedirs(app.instance_path, exist_ok=True)
|
||||
|
||||
# Configure logging
|
||||
configure_logging(app)
|
||||
|
||||
# Initialize extensions
|
||||
init_extensions(app)
|
||||
|
||||
# Initialize plugin manager
|
||||
with app.app_context():
|
||||
plugin_manager.init_app(app, db)
|
||||
|
||||
# Register core blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Personal API token auth shim: recognize `Bearer shopdb_pat_...` before
|
||||
# any JWT decode and mint a request-scoped JWT for the token's owner.
|
||||
from .utils.apitoken_auth import install_apitoken_auth
|
||||
install_apitoken_auth(app)
|
||||
|
||||
# Register CLI commands
|
||||
register_cli_commands(app)
|
||||
|
||||
# Register error handlers
|
||||
register_error_handlers(app)
|
||||
|
||||
# Serve Vue frontend
|
||||
register_frontend_routes(app)
|
||||
|
||||
# JWT user loader (identity is a string in JWT, convert to int for DB lookup)
|
||||
@jwt.user_lookup_loader
|
||||
def user_lookup_callback(_jwt_header, jwt_data):
|
||||
from .core.models import User
|
||||
identity = jwt_data["sub"]
|
||||
return db.session.get(User, int(identity))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
CORE_BLUEPRINT_NAMES = (
|
||||
'auth',
|
||||
'assets',
|
||||
'modeltypes',
|
||||
'plugins',
|
||||
'vendors',
|
||||
'models',
|
||||
'businessunits',
|
||||
'locations',
|
||||
'operatingsystems',
|
||||
'dashboard',
|
||||
'dashboarddefaults',
|
||||
'applications',
|
||||
'supportteams',
|
||||
'search',
|
||||
'reports',
|
||||
'collector',
|
||||
'settings',
|
||||
'auditlogs',
|
||||
'users',
|
||||
'customfields',
|
||||
'setup',
|
||||
'pluginui',
|
||||
'apitokens',
|
||||
)
|
||||
|
||||
|
||||
def register_blueprints(app: Flask):
|
||||
"""Register core API blueprints from CORE_BLUEPRINT_NAMES.
|
||||
|
||||
Each entry maps to an attribute `<name>_bp` exported by
|
||||
`shopdb.core.api` and a URL prefix `/api/<name>`. Adding a new
|
||||
core resource is one entry in CORE_BLUEPRINT_NAMES, not a 3-line
|
||||
edit in this function.
|
||||
"""
|
||||
from .core import api as api_module
|
||||
|
||||
api_prefix = '/api'
|
||||
for name in CORE_BLUEPRINT_NAMES:
|
||||
attr_name = f'{name}_bp'
|
||||
if not hasattr(api_module, attr_name):
|
||||
raise RuntimeError(
|
||||
f'Core blueprint "{attr_name}" missing from shopdb.core.api. '
|
||||
f'Either add it or remove "{name}" from CORE_BLUEPRINT_NAMES.'
|
||||
)
|
||||
bp = getattr(api_module, attr_name)
|
||||
app.register_blueprint(bp, url_prefix=f'{api_prefix}/{name}')
|
||||
|
||||
|
||||
def register_cli_commands(app: Flask):
|
||||
"""Register Flask CLI commands."""
|
||||
from .plugins.cli import plugin_cli
|
||||
from .cli import db_cli, seed_cli, relationships_cli
|
||||
|
||||
app.cli.add_command(plugin_cli)
|
||||
app.cli.add_command(db_cli)
|
||||
app.cli.add_command(seed_cli)
|
||||
app.cli.add_command(relationships_cli)
|
||||
|
||||
|
||||
def register_error_handlers(app: Flask):
|
||||
"""Register error handlers."""
|
||||
from .utils.responses import error_response, ErrorCodes
|
||||
from .exceptions import ShopDBException
|
||||
|
||||
@app.errorhandler(ShopDBException)
|
||||
def handle_shopdb_exception(error):
|
||||
http_codes = {
|
||||
'NOT_FOUND': 404,
|
||||
'UNAUTHORIZED': 401,
|
||||
'FORBIDDEN': 403,
|
||||
'CONFLICT': 409,
|
||||
'VALIDATION_ERROR': 400,
|
||||
}
|
||||
http_code = http_codes.get(error.code, 400)
|
||||
return error_response(
|
||||
error.code,
|
||||
error.message,
|
||||
details=error.details,
|
||||
http_code=http_code
|
||||
)
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found_error(error):
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
'Resource not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
return error_response(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'An internal error occurred',
|
||||
http_code=500
|
||||
)
|
||||
|
||||
@app.errorhandler(401)
|
||||
def unauthorized_error(error):
|
||||
return error_response(
|
||||
ErrorCodes.UNAUTHORIZED,
|
||||
'Authentication required',
|
||||
http_code=401
|
||||
)
|
||||
|
||||
|
||||
def register_frontend_routes(app: Flask):
|
||||
"""Serve Vue frontend static files."""
|
||||
frontend_dist = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'frontend', 'dist')
|
||||
|
||||
@app.route('/', defaults={'path': ''})
|
||||
@app.route('/<path:path>')
|
||||
def serve_frontend(path):
|
||||
# Don't serve API routes as frontend
|
||||
if path.startswith('api/'):
|
||||
from .utils.responses import error_response, ErrorCodes
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'API endpoint not found', http_code=404)
|
||||
|
||||
# Try to serve a static asset directly. send_from_directory handles
|
||||
# the safe-join + 404 itself; no explicit existence probe needed
|
||||
# (the probe was a path-traversal risk surface).
|
||||
if path:
|
||||
try:
|
||||
return send_from_directory(frontend_dist, path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return send_from_directory(frontend_dist, 'index.html')
|
||||
|
||||
|
||||
def configure_logging(app: Flask):
|
||||
"""Configure application logging."""
|
||||
log_level = app.config.get('LOG_LEVEL', 'INFO')
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level),
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
271
shopdb/api/__init__.py
Normal file
271
shopdb/api/__init__.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""Public API namespace exposed to plugins.
|
||||
|
||||
Plugin authors at sister sites import from this module. The contract is
|
||||
locked in ADR-001 and versioned per ADR-002. Helpers added here become
|
||||
part of the platform contract; bumps follow ADR-002 rules.
|
||||
|
||||
Currently exposed:
|
||||
- audit_log: record an audit log entry with consistent schema
|
||||
- resolve_asset_position: compute an asset's resolved map position
|
||||
|
||||
Setting helpers are exposed via BasePlugin instance methods
|
||||
(plugin.get_setting, plugin.set_setting), not from this namespace.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
# -- Plugin contract surface (ADR-001, versioned per ADR-002) ----------------
|
||||
# Everything a plugin is allowed to import from the core lives here. Plugins
|
||||
# import these from `shopdb.api`, never from internal paths like
|
||||
# `shopdb.core.models.*` or `shopdb.extensions`. The contract test
|
||||
# (tests/test_plugin_contract.py) enforces this. Adding a name here is an
|
||||
# additive (minor) contract change; removing one is breaking (major).
|
||||
|
||||
# Infrastructure
|
||||
from shopdb.extensions import db, cache
|
||||
|
||||
# Model base classes for declaring plugin tables
|
||||
from shopdb.core.models.base import BaseModel, AuditMixin
|
||||
|
||||
# Core domain models plugins legitimately reference (the asset contract)
|
||||
from shopdb.core.models import (
|
||||
Asset,
|
||||
AssetType,
|
||||
AssetStatus,
|
||||
Vendor,
|
||||
Model,
|
||||
Communication,
|
||||
CommunicationType,
|
||||
Location,
|
||||
Setting,
|
||||
AuditLog,
|
||||
Application,
|
||||
AppVersion,
|
||||
OperatingSystem,
|
||||
AssetRelationship,
|
||||
RelationshipType,
|
||||
)
|
||||
|
||||
# Response + pagination helpers for plugin API blueprints
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes,
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
|
||||
# Authorization decorators for gating plugin write routes
|
||||
from shopdb.utils.authz import require_permission, require_role
|
||||
|
||||
# Service-token authorization for unattended plugin endpoints (collector,
|
||||
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token
|
||||
# internals.
|
||||
from shopdb.utils.apitoken_auth import service_token_authorized
|
||||
|
||||
# Import-mode helpers: preserve legacy timestamps during a bulk data import
|
||||
from shopdb.utils.import_mode import (
|
||||
apply_import_timestamps,
|
||||
import_mode_active,
|
||||
parse_import_datetime,
|
||||
)
|
||||
|
||||
# Dualpath single-machine collapse (a dual-bay pair is one physical machine)
|
||||
from shopdb.core.services.dualpath import (
|
||||
resolve_dualpath_pairs,
|
||||
dualpath_single_machine_enabled,
|
||||
)
|
||||
|
||||
# Legacy employee directory lookup (read-only) used by notifications
|
||||
from shopdb.utils.employee_db import employee_connection
|
||||
|
||||
# CMMC USB check-in/out database (read-write) used by the usb plugin
|
||||
from shopdb.utils.cmmc_usb_db import cmmc_usb_connection
|
||||
|
||||
|
||||
def audit_log(
|
||||
action: str,
|
||||
entitytype: str,
|
||||
entityid: int = None,
|
||||
entityname: str = None,
|
||||
changes: Dict = None,
|
||||
details: Dict = None,
|
||||
) -> AuditLog:
|
||||
"""Record an audit log entry with the framework's standard schema.
|
||||
|
||||
Plugins call this to record state changes on their own assets in a
|
||||
way that is consistent with core auditing. The function delegates to
|
||||
AuditLog.log() which already captures the current user, IP address,
|
||||
and user agent from the Flask request context.
|
||||
|
||||
Args:
|
||||
action: Action verb in past tense ('created', 'updated', 'deleted')
|
||||
entitytype: Class name of the entity affected ('Computer', 'Printer')
|
||||
entityid: Primary key of the entity
|
||||
entityname: Human-readable identifier (hostname, asset number)
|
||||
changes: Dict with 'before' and 'after' snapshots for updates
|
||||
details: Arbitrary additional context
|
||||
|
||||
Returns:
|
||||
The created AuditLog instance, already committed to the DB.
|
||||
"""
|
||||
return AuditLog.log(
|
||||
action=action,
|
||||
entitytype=entitytype,
|
||||
entityid=entityid,
|
||||
entityname=entityname,
|
||||
changes=changes,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
# Cap how deep the relationship-walk traverses before giving up. ADR-001
|
||||
# specifies a max walk depth of 3 to bound the work per request, with a
|
||||
# visited-set guarding against cycles. Past this depth the walk treats the
|
||||
# next hop as if it had no position to contribute.
|
||||
_POSITION_WALK_MAX_DEPTH = 3
|
||||
|
||||
# Relationship type names whose edges are eligible for the inheritance walk
|
||||
# when inheritsposition is true on the edge. Ordered by priority per
|
||||
# ADR-001 ("partof first, then controls"). Edges of other types are never
|
||||
# followed even if inheritsposition is true.
|
||||
_INHERITABLE_TYPES = ('partof', 'controls')
|
||||
|
||||
|
||||
def _walk_related_for_position(asset, visited, depth):
|
||||
"""Recursive helper for resolve_asset_position relationship walk. Returns
|
||||
a (mapx, mapy) tuple from the first related asset whose position
|
||||
resolves, or None. Visited tracks assetids already explored to break
|
||||
cycles."""
|
||||
if depth >= _POSITION_WALK_MAX_DEPTH:
|
||||
return None
|
||||
aid = getattr(asset, 'assetid', None)
|
||||
if aid is None or aid in visited:
|
||||
return None
|
||||
visited.add(aid)
|
||||
|
||||
edges = []
|
||||
for rel in list(getattr(asset, 'outgoing_relationships', []) or []):
|
||||
edges.append((rel, getattr(rel, 'targetasset', None)))
|
||||
for rel in list(getattr(asset, 'incoming_relationships', []) or []):
|
||||
edges.append((rel, getattr(rel, 'sourceasset', None)))
|
||||
|
||||
def _priority(edge):
|
||||
rel = edge[0]
|
||||
rtype = getattr(rel, 'relationshiptype', None)
|
||||
type_name = getattr(rtype, 'relationshiptype', '') if rtype else ''
|
||||
try:
|
||||
return _INHERITABLE_TYPES.index(type_name)
|
||||
except ValueError:
|
||||
return len(_INHERITABLE_TYPES)
|
||||
|
||||
edges.sort(key=_priority)
|
||||
|
||||
for rel, neighbor in edges:
|
||||
if neighbor is None:
|
||||
continue
|
||||
if not getattr(rel, 'inheritsposition', False):
|
||||
continue
|
||||
if not getattr(rel, 'isactive', True):
|
||||
continue
|
||||
rtype = getattr(rel, 'relationshiptype', None)
|
||||
type_name = getattr(rtype, 'relationshiptype', '') if rtype else ''
|
||||
if type_name not in _INHERITABLE_TYPES:
|
||||
continue
|
||||
|
||||
n_mapx = getattr(neighbor, 'mapx', None)
|
||||
n_mapy = getattr(neighbor, 'mapy', None)
|
||||
if n_mapx is not None and n_mapy is not None:
|
||||
return (n_mapx, n_mapy)
|
||||
|
||||
recursed = _walk_related_for_position(neighbor, visited, depth + 1)
|
||||
if recursed is not None:
|
||||
return recursed
|
||||
return None
|
||||
|
||||
|
||||
def resolve_asset_position(asset) -> Optional[Dict[str, Any]]:
|
||||
"""Compute the resolved map position for an asset.
|
||||
|
||||
Per ADR-001, position resolution follows this priority chain:
|
||||
1. Asset-specific override (asset.mapx, asset.mapy)
|
||||
2. Walk relationships where inheritsposition is true on edges of type
|
||||
partof or controls (partof first), depth-limited and cycle-safe
|
||||
3. Asset's location coords (asset.location.mapx, .mapy)
|
||||
4. None (asset is unplaced, rendered in a tray)
|
||||
|
||||
Returns a dict {'mapx', 'mapy', 'positionsource'} where positionsource
|
||||
is one of 'self', 'related', 'location'. Returns None when no priority
|
||||
yields coordinates.
|
||||
"""
|
||||
mapx = getattr(asset, 'mapx', None)
|
||||
mapy = getattr(asset, 'mapy', None)
|
||||
if mapx is not None and mapy is not None:
|
||||
return {'mapx': mapx, 'mapy': mapy, 'positionsource': 'self'}
|
||||
|
||||
related = _walk_related_for_position(asset, set(), 0)
|
||||
if related is not None:
|
||||
return {'mapx': related[0], 'mapy': related[1], 'positionsource': 'related'}
|
||||
|
||||
location = getattr(asset, 'location', None)
|
||||
if location is not None:
|
||||
loc_mapx = getattr(location, 'mapx', None)
|
||||
loc_mapy = getattr(location, 'mapy', None)
|
||||
if loc_mapx is not None and loc_mapy is not None:
|
||||
return {
|
||||
'mapx': loc_mapx,
|
||||
'mapy': loc_mapy,
|
||||
'positionsource': 'location',
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Helpers
|
||||
'audit_log',
|
||||
'resolve_asset_position',
|
||||
'resolve_dualpath_pairs',
|
||||
'dualpath_single_machine_enabled',
|
||||
# Infrastructure
|
||||
'db',
|
||||
'cache',
|
||||
# Model bases
|
||||
'BaseModel',
|
||||
'AuditMixin',
|
||||
# Core models
|
||||
'Asset',
|
||||
'AssetType',
|
||||
'AssetStatus',
|
||||
'Vendor',
|
||||
'Model',
|
||||
'Communication',
|
||||
'CommunicationType',
|
||||
'Location',
|
||||
'Setting',
|
||||
'AuditLog',
|
||||
'Application',
|
||||
'AppVersion',
|
||||
'OperatingSystem',
|
||||
'AssetRelationship',
|
||||
'RelationshipType',
|
||||
# Response + pagination helpers
|
||||
'success_response',
|
||||
'error_response',
|
||||
'paginated_response',
|
||||
'ErrorCodes',
|
||||
'get_pagination_params',
|
||||
'paginate_query',
|
||||
# Authorization decorators
|
||||
'require_permission',
|
||||
'require_role',
|
||||
'service_token_authorized',
|
||||
# Import-mode helpers
|
||||
'apply_import_timestamps',
|
||||
'import_mode_active',
|
||||
'parse_import_datetime',
|
||||
# Legacy employee directory
|
||||
'employee_connection',
|
||||
# CMMC USB check-in/out database
|
||||
'cmmc_usb_connection',
|
||||
]
|
||||
513
shopdb/cli/__init__.py
Normal file
513
shopdb/cli/__init__.py
Normal file
@@ -0,0 +1,513 @@
|
||||
"""Flask CLI commands."""
|
||||
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
|
||||
@click.group('db-utils')
|
||||
def db_cli():
|
||||
"""Database utility commands."""
|
||||
pass
|
||||
|
||||
|
||||
@db_cli.command('create-all')
|
||||
@with_appcontext
|
||||
def create_all():
|
||||
"""Create all database tables."""
|
||||
from shopdb.extensions import db
|
||||
|
||||
db.create_all()
|
||||
click.echo(click.style("All tables created.", fg='green'))
|
||||
|
||||
|
||||
@db_cli.command('preflight')
|
||||
@with_appcontext
|
||||
def preflight():
|
||||
"""Check install prerequisites before `flask db upgrade`.
|
||||
|
||||
Reports what must be fixed/installed (Python, required env, DB connectivity,
|
||||
and the MySQL 5.6 utf8mb4 index flags). Exits non-zero if anything blocks.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
from sqlalchemy import text
|
||||
from flask import current_app
|
||||
from shopdb.extensions import db
|
||||
|
||||
failures = []
|
||||
warnings = []
|
||||
|
||||
def ok(msg):
|
||||
click.echo(click.style(' PASS ', fg='green') + msg)
|
||||
|
||||
def fail(msg, fix):
|
||||
failures.append((msg, fix))
|
||||
click.echo(click.style(' FAIL ', fg='red') + msg)
|
||||
click.echo(click.style(' fix: ', fg='red') + fix)
|
||||
|
||||
def warn(msg, fix):
|
||||
warnings.append((msg, fix))
|
||||
click.echo(click.style(' WARN ', fg='yellow') + msg)
|
||||
click.echo(click.style(' ', fg='yellow') + fix)
|
||||
|
||||
click.echo(click.style('ShopDB preflight', bold=True))
|
||||
|
||||
# Python
|
||||
py = sys.version_info
|
||||
if py >= (3, 9):
|
||||
ok(f'Python {py.major}.{py.minor}.{py.micro}')
|
||||
else:
|
||||
fail(f'Python {py.major}.{py.minor} is too old',
|
||||
'Install Python 3.9 or newer.')
|
||||
|
||||
# Required config
|
||||
for key in ('SECRET_KEY', 'JWT_SECRET_KEY', 'DATABASE_URL'):
|
||||
value = current_app.config.get(key) or os.environ.get(key)
|
||||
if value and 'change' not in str(value).lower() and 'dev-' not in str(value).lower():
|
||||
ok(f'{key} is set')
|
||||
else:
|
||||
fail(f'{key} is missing or a dev default',
|
||||
f'Set {key} in .env (64+ random chars for the secrets).')
|
||||
|
||||
# DB connectivity + MySQL index prerequisites
|
||||
try:
|
||||
version = db.session.execute(text('SELECT VERSION()')).scalar()
|
||||
ok(f'Database reachable (MySQL {version})')
|
||||
except Exception as exc:
|
||||
fail('Cannot connect to the database', f'Check DATABASE_URL / server. ({exc})')
|
||||
version = ''
|
||||
|
||||
if version and version.startswith('5.6'):
|
||||
variables = {}
|
||||
for name in ('innodb_large_prefix', 'innodb_file_format', 'innodb_file_per_table'):
|
||||
try:
|
||||
row = db.session.execute(text(f"SHOW VARIABLES LIKE '{name}'")).fetchone()
|
||||
variables[name] = row[1] if row else None
|
||||
except Exception:
|
||||
variables[name] = None
|
||||
needed = {'innodb_large_prefix': 'ON', 'innodb_file_format': 'Barracuda',
|
||||
'innodb_file_per_table': 'ON'}
|
||||
bad = [n for n, want in needed.items() if str(variables.get(n)).lower() != want.lower()]
|
||||
if bad:
|
||||
fail('MySQL 5.6 index flags not set: ' + ', '.join(bad),
|
||||
'Add to my.cnf [mysqld]: innodb_file_per_table=1, '
|
||||
'innodb_file_format=Barracuda, innodb_large_prefix=1 (then restart). '
|
||||
'Otherwise `flask db upgrade` fails with error 1071.')
|
||||
else:
|
||||
ok('MySQL 5.6 index flags OK (Barracuda + large_prefix)')
|
||||
elif version:
|
||||
ok('MySQL 5.7+/8.0 - no extra index flags needed')
|
||||
|
||||
click.echo('')
|
||||
if failures:
|
||||
click.echo(click.style(f'{len(failures)} blocker(s). Fix them before installing.', fg='red', bold=True))
|
||||
raise SystemExit(1)
|
||||
click.echo(click.style('All prerequisites met.' + (f' ({len(warnings)} warning(s))' if warnings else ''), fg='green', bold=True))
|
||||
|
||||
|
||||
@db_cli.command('drop-all')
|
||||
@click.confirmation_option(prompt='This will delete ALL data. Are you sure?')
|
||||
@with_appcontext
|
||||
def drop_all():
|
||||
"""Drop all database tables."""
|
||||
from shopdb.extensions import db
|
||||
|
||||
db.drop_all()
|
||||
click.echo(click.style("All tables dropped.", fg='yellow'))
|
||||
|
||||
|
||||
@click.group('seed')
|
||||
def seed_cli():
|
||||
"""Database seeding commands."""
|
||||
pass
|
||||
|
||||
|
||||
@seed_cli.command('reference-data')
|
||||
@with_appcontext
|
||||
def seed_reference_data():
|
||||
"""Seed reference data (model types, statuses, etc.)."""
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import (ModelType, OperatingSystem, AssetStatus,
|
||||
LocationType, CommunicationType)
|
||||
from shopdb.core.models.relationship import RelationshipType
|
||||
|
||||
# Model types (type the vendor models catalog)
|
||||
model_types = [
|
||||
{'modeltype': 'CNC Mill', 'category': 'Equipment', 'description': 'CNC Milling Machine'},
|
||||
{'modeltype': 'CNC Lathe', 'category': 'Equipment', 'description': 'CNC Lathe'},
|
||||
{'modeltype': 'CMM', 'category': 'Equipment', 'description': 'Coordinate Measuring Machine'},
|
||||
{'modeltype': 'EDM', 'category': 'Equipment', 'description': 'Electrical Discharge Machine'},
|
||||
{'modeltype': 'Grinder', 'category': 'Equipment', 'description': 'Grinding Machine'},
|
||||
{'modeltype': 'Inspection Station', 'category': 'Equipment', 'description': 'Inspection Station'},
|
||||
{'modeltype': 'Desktop PC', 'category': 'PC', 'description': 'Desktop Computer'},
|
||||
{'modeltype': 'Laptop', 'category': 'PC', 'description': 'Laptop Computer'},
|
||||
{'modeltype': 'Shopfloor PC', 'category': 'PC', 'description': 'Shopfloor Computer'},
|
||||
{'modeltype': 'Server', 'category': 'Network', 'description': 'Server'},
|
||||
{'modeltype': 'Switch', 'category': 'Network', 'description': 'Network Switch'},
|
||||
{'modeltype': 'Access Point', 'category': 'Network', 'description': 'Wireless Access Point'},
|
||||
]
|
||||
|
||||
for mt_data in model_types:
|
||||
existing = ModelType.query.filter_by(modeltype=mt_data['modeltype']).first()
|
||||
if not existing:
|
||||
mt = ModelType(**mt_data)
|
||||
db.session.add(mt)
|
||||
|
||||
# Asset statuses (canonical set - the asset model is the contract)
|
||||
asset_statuses = [
|
||||
{'status': 'In Use', 'description': 'Currently in use', 'color': '#28a745'},
|
||||
{'status': 'Inventory', 'description': 'In inventory', 'color': '#17a2b8'},
|
||||
{'status': 'In Repair', 'description': 'Being repaired', 'color': '#ffc107'},
|
||||
{'status': 'Retired', 'description': 'No longer in use', 'color': '#6c757d'},
|
||||
{'status': 'Returned', 'description': 'Returned to vendor or owner', 'color': '#fd7e14'},
|
||||
{'status': 'Warrantied', 'description': 'Under warranty service', 'color': '#20c997'},
|
||||
{'status': 'Lost', 'description': 'Lost or missing', 'color': '#dc3545'},
|
||||
]
|
||||
|
||||
for s_data in asset_statuses:
|
||||
existing = AssetStatus.query.filter_by(status=s_data['status']).first()
|
||||
if not existing:
|
||||
db.session.add(AssetStatus(isactive=True, **s_data))
|
||||
elif existing.isactive is not True:
|
||||
existing.isactive = True
|
||||
|
||||
# Location types (ADR-001)
|
||||
location_types = ['section', 'cell', 'subcell', 'operation', 'meetingroom',
|
||||
'lab', 'office', 'storage', 'hallway', 'networkcloset',
|
||||
'building']
|
||||
for lt in location_types:
|
||||
if not LocationType.query.filter_by(locationtype=lt).first():
|
||||
db.session.add(LocationType(locationtype=lt, isactive=True))
|
||||
|
||||
# Communication types (how an asset is reached / its interfaces). The IP
|
||||
# type is what the printer + network create routes attach an ipaddress to,
|
||||
# so it must exist before any asset import.
|
||||
comm_types = [
|
||||
('IP', 'IP address / network reachable'),
|
||||
('Serial', 'Serial (RS-232) connection'),
|
||||
('Network_Interface', 'Physical network interface (MAC/port)'),
|
||||
('USB', 'USB connection'),
|
||||
('Parallel', 'Parallel port connection'),
|
||||
('VNC', 'VNC remote access'),
|
||||
('FTP', 'FTP file transfer'),
|
||||
('DNC', 'Direct numerical control link'),
|
||||
]
|
||||
for comtype, description in comm_types:
|
||||
if not CommunicationType.query.filter_by(comtype=comtype).first():
|
||||
db.session.add(CommunicationType(comtype=comtype, description=description))
|
||||
|
||||
# Operating systems
|
||||
os_list = [
|
||||
{'osname': 'Windows 10', 'osversion': '10.0'},
|
||||
{'osname': 'Windows 11', 'osversion': '11.0'},
|
||||
{'osname': 'Windows Server 2019', 'osversion': '2019'},
|
||||
{'osname': 'Windows Server 2022', 'osversion': '2022'},
|
||||
{'osname': 'Linux', 'osversion': 'Various'},
|
||||
]
|
||||
|
||||
for os_data in os_list:
|
||||
existing = OperatingSystem.query.filter_by(osname=os_data['osname']).first()
|
||||
if not existing:
|
||||
os_obj = OperatingSystem(**os_data)
|
||||
db.session.add(os_obj)
|
||||
|
||||
# Connection types (pre-1.0 legacy; kept for backward compat with
|
||||
# existing relationship rows. New ADR-001 code reasons about the three
|
||||
# canonical types below via free-text label.)
|
||||
# all symmetric physical/network links -> isdirectional=False so the
|
||||
# relationships card shows one direction-blind "connected" entry per peer.
|
||||
connection_types = [
|
||||
{'relationshiptype': 'Serial Cable', 'description': 'RS-232 or similar serial connection', 'isdirectional': False},
|
||||
{'relationshiptype': 'Direct Ethernet', 'description': 'Direct network cable (airgapped)', 'isdirectional': False},
|
||||
{'relationshiptype': 'USB', 'description': 'USB connection', 'isdirectional': False},
|
||||
{'relationshiptype': 'WiFi', 'description': 'Wireless network connection', 'isdirectional': False},
|
||||
{'relationshiptype': 'Dualpath', 'description': 'Redundant/failover network path', 'isdirectional': False},
|
||||
]
|
||||
|
||||
for ct_data in connection_types:
|
||||
existing = RelationshipType.query.filter_by(relationshiptype=ct_data['relationshiptype']).first()
|
||||
if not existing:
|
||||
ct = RelationshipType(**ct_data)
|
||||
db.session.add(ct)
|
||||
|
||||
# ADR-001 canonical relationship types. Created first, then their
|
||||
# propagation rails are seeded as relationshiptypepropagations rows (M:N).
|
||||
# All idempotent.
|
||||
#
|
||||
# MySQL collation is case-insensitive by default, which would let a
|
||||
# legacy capitalized row (e.g. "Controls") match the lowercase
|
||||
# "controls" check and skip the insert. Force binary comparison via
|
||||
# collate so the three ADR-001 types stay distinct from any legacy
|
||||
# rows with the same spelling but different case.
|
||||
from sqlalchemy import func, literal
|
||||
def _lookup_binary(name):
|
||||
dialect = db.engine.dialect.name
|
||||
if dialect == 'mysql':
|
||||
return RelationshipType.query.filter(
|
||||
func.binary(RelationshipType.relationshiptype) == literal(name)
|
||||
).first()
|
||||
return RelationshipType.query.filter_by(relationshiptype=name).first()
|
||||
|
||||
adr_types = [
|
||||
{'relationshiptype': 'partof', 'description': 'Composition / sub-assembly (ADR-001)', 'isdirectional': True},
|
||||
{'relationshiptype': 'controls', 'description': 'Operational authority over another asset (ADR-001)', 'isdirectional': True},
|
||||
{'relationshiptype': 'connectedto', 'description': 'Network or data link without authority (ADR-001)', 'isdirectional': False},
|
||||
]
|
||||
for at in adr_types:
|
||||
if not _lookup_binary(at['relationshiptype']):
|
||||
db.session.add(RelationshipType(**at))
|
||||
db.session.flush()
|
||||
|
||||
# Seed `controls` propagation rails as M:N rows. controls -> partof
|
||||
# (declared; directional rail, not consumed yet) and controls -> Dualpath
|
||||
# (consumed; a dual-bay pair shares one controller so both bays carry
|
||||
# controls). Idempotent, resolved by name, skipped if a type is missing.
|
||||
from shopdb.core.models.relationship import RelationshipTypePropagation
|
||||
|
||||
def _seed_propagation(sourcename, throughname):
|
||||
source = _lookup_binary(sourcename)
|
||||
through = _lookup_binary(throughname)
|
||||
if not source or not through:
|
||||
return
|
||||
exists = RelationshipTypePropagation.query.filter_by(
|
||||
relationshiptypeid=source.relationshiptypeid,
|
||||
throughtypeid=through.relationshiptypeid,
|
||||
).first()
|
||||
if not exists:
|
||||
db.session.add(RelationshipTypePropagation(
|
||||
relationshiptypeid=source.relationshiptypeid,
|
||||
throughtypeid=through.relationshiptypeid,
|
||||
))
|
||||
|
||||
_seed_propagation('controls', 'partof')
|
||||
_seed_propagation('controls', 'Dualpath')
|
||||
|
||||
# Default-printer link: a PC asset -> its default printer asset. Read by the
|
||||
# printer-installer endpoint (parity with classic apipcdefaultprinter.asp).
|
||||
# Attribute-style edge, not a position rail, so no propagation.
|
||||
if not _lookup_binary('defaultprinter'):
|
||||
db.session.add(RelationshipType(
|
||||
relationshiptype='defaultprinter',
|
||||
description='PC to its default printer (installer preselect, ADR-001)'
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
click.echo(click.style("Reference data seeded.", fg='green'))
|
||||
|
||||
|
||||
@seed_cli.command('test-user')
|
||||
@with_appcontext
|
||||
def seed_test_user():
|
||||
"""Create a test admin user (admin / admin123). DEV ONLY."""
|
||||
from flask import current_app
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import User, Role
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
# Refuse in production: this seeds a well-known credential. Sites bootstrap
|
||||
# a real admin with `flask seed admin` (generated password) or the wizard.
|
||||
if not (current_app.config.get('DEBUG') or current_app.config.get('TESTING')):
|
||||
raise click.ClickException(
|
||||
'seed test-user is dev-only (creates admin/admin123). '
|
||||
'Use `flask seed admin` to create a production admin.')
|
||||
|
||||
# Create admin role if not exists
|
||||
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)
|
||||
|
||||
# Create test user
|
||||
test_user = User.query.filter_by(username='admin').first()
|
||||
if not test_user:
|
||||
test_user = User(
|
||||
username='admin',
|
||||
email='admin@localhost',
|
||||
passwordhash=generate_password_hash('admin123'),
|
||||
isactive=True
|
||||
)
|
||||
test_user.roles.append(admin_role)
|
||||
db.session.add(test_user)
|
||||
db.session.commit()
|
||||
click.echo(click.style("Test user created: admin / admin123", fg='green'))
|
||||
else:
|
||||
click.echo(click.style("Test user already exists", fg='yellow'))
|
||||
|
||||
|
||||
@seed_cli.command('admin')
|
||||
@click.option('--username', required=True, help='Admin login username')
|
||||
@click.option('--email', required=True, help='Admin email address')
|
||||
@click.option('--password', default=None,
|
||||
help='Admin password. Omit to generate a strong one and print it once.')
|
||||
@with_appcontext
|
||||
def seed_admin(username, email, password):
|
||||
"""Create the first admin user for a new site.
|
||||
|
||||
Password is generated and printed ONCE if not supplied. Store it safely.
|
||||
"""
|
||||
import secrets
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import User, Role
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
click.echo(click.style(f'User "{username}" already exists.', fg='yellow'))
|
||||
return
|
||||
|
||||
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)
|
||||
|
||||
generated = password is None
|
||||
if generated:
|
||||
password = secrets.token_urlsafe(12)
|
||||
|
||||
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()
|
||||
|
||||
click.echo(click.style(f'Admin "{username}" created.', fg='green'))
|
||||
if generated:
|
||||
click.echo(click.style('=' * 52, fg='cyan'))
|
||||
click.echo(click.style(f' Password: {password}', fg='cyan', bold=True))
|
||||
click.echo(click.style(' Store this now - it will not be shown again.', fg='cyan'))
|
||||
click.echo(click.style('=' * 52, fg='cyan'))
|
||||
|
||||
|
||||
@click.group('relationships')
|
||||
def relationships_cli():
|
||||
"""Asset-relationship maintenance commands."""
|
||||
pass
|
||||
|
||||
|
||||
@relationships_cli.command('propagate')
|
||||
@with_appcontext
|
||||
def propagate_relationships():
|
||||
"""Backfill propagated relationship rows across symmetric rails.
|
||||
|
||||
Scans every existing relationship of a type that propagates through a
|
||||
symmetric through-type (e.g. controls through Dualpath) and creates the
|
||||
missing fanned-out rows. Idempotent. Also serves the legacy-import flow:
|
||||
the import creates controls links on primary bays, this fans them out to
|
||||
the Dualpath partner bays.
|
||||
"""
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
||||
from shopdb.core.api.assets import propagate_relationship
|
||||
|
||||
# types that actually propagate through at least one symmetric through-type
|
||||
propagating_ids = [
|
||||
t.relationshiptypeid for t in RelationshipType.query.all()
|
||||
if any(not through.isdirectional for through in t.propagatesthrough)
|
||||
]
|
||||
|
||||
total = 0
|
||||
if propagating_ids:
|
||||
rels = AssetRelationship.query.filter(
|
||||
AssetRelationship.relationshiptypeid.in_(propagating_ids),
|
||||
AssetRelationship.isactive == True,
|
||||
).all()
|
||||
for rel in rels:
|
||||
total += len(propagate_relationship(rel))
|
||||
db.session.commit()
|
||||
|
||||
click.echo(click.style(f"Propagated {total} relationship row(s).", fg='green'))
|
||||
|
||||
|
||||
@relationships_cli.command('fix-controls-direction')
|
||||
@with_appcontext
|
||||
def fix_controls_direction():
|
||||
"""Flip reversed legacy controls rows to PC -> machine.
|
||||
|
||||
Legacy import stores controls as machine -> PC, which reads as the machine
|
||||
having authority over the PC. In reality the PC is the controller (it
|
||||
sends programs to the machine and receives logs), so per ADR-001 the PC
|
||||
must be the source. Flips every active controls row whose source is a
|
||||
machine asset and target is a computer asset. If the flipped row already
|
||||
exists, the reversed duplicate is deactivated instead. Idempotent.
|
||||
"""
|
||||
from sqlalchemy.orm import aliased
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
||||
|
||||
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
|
||||
if not controls:
|
||||
click.echo(click.style("No 'controls' relationship type; nothing to do.", fg='yellow'))
|
||||
return
|
||||
|
||||
sourceasset = aliased(Asset)
|
||||
targetasset = aliased(Asset)
|
||||
sourcetype = aliased(AssetType)
|
||||
targettype = aliased(AssetType)
|
||||
|
||||
reversed_rows = (
|
||||
AssetRelationship.query
|
||||
.join(sourceasset, AssetRelationship.sourceassetid == sourceasset.assetid)
|
||||
.join(targetasset, AssetRelationship.targetassetid == targetasset.assetid)
|
||||
.join(sourcetype, sourceasset.assettypeid == sourcetype.assettypeid)
|
||||
.join(targettype, targetasset.assettypeid == targettype.assettypeid)
|
||||
.filter(
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.isactive == True,
|
||||
sourcetype.assettype == 'machine',
|
||||
targettype.assettype == 'computer',
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
flipped = 0
|
||||
deactivated = 0
|
||||
for row in reversed_rows:
|
||||
duplicate = AssetRelationship.query.filter_by(
|
||||
sourceassetid=row.targetassetid,
|
||||
targetassetid=row.sourceassetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
).first()
|
||||
if duplicate:
|
||||
row.isactive = False # flipped row already exists, retire this one
|
||||
deactivated += 1
|
||||
else:
|
||||
row.sourceassetid, row.targetassetid = row.targetassetid, row.sourceassetid
|
||||
flipped += 1
|
||||
db.session.commit()
|
||||
|
||||
click.echo(click.style(
|
||||
f"Flipped {flipped} controls row(s) to PC -> machine"
|
||||
f" ({deactivated} reversed duplicate(s) deactivated).", fg='green'))
|
||||
|
||||
|
||||
@seed_cli.command('permissions')
|
||||
@with_appcontext
|
||||
def seed_permissions():
|
||||
"""Seed predefined permissions."""
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Permission
|
||||
|
||||
created = Permission.seed()
|
||||
db.session.commit()
|
||||
click.echo(click.style(f"{created} permissions created.", fg='green'))
|
||||
|
||||
|
||||
@seed_cli.command('settings')
|
||||
@with_appcontext
|
||||
def seed_settings():
|
||||
"""Seed default system settings."""
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Setting
|
||||
from shopdb.core.api.settings import build_default_settings
|
||||
|
||||
defaults = build_default_settings()
|
||||
|
||||
created = 0
|
||||
for d in defaults:
|
||||
if not Setting.query.filter_by(key=d['key']).first():
|
||||
setting = Setting(**d)
|
||||
db.session.add(setting)
|
||||
created += 1
|
||||
|
||||
db.session.commit()
|
||||
click.echo(click.style(f"{created} default settings created.", fg='green'))
|
||||
186
shopdb/config.py
Normal file
186
shopdb/config.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Flask application configuration."""
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""Raised when required configuration is missing or unsafe."""
|
||||
|
||||
|
||||
def _required_env(varname):
|
||||
"""Read an env var; raise ConfigError if missing or empty."""
|
||||
value = os.environ.get(varname)
|
||||
if not value:
|
||||
raise ConfigError(
|
||||
f'{varname} is required in production. Set it in the environment '
|
||||
f'before starting the app. Insecure defaults are not permitted in '
|
||||
f'ProductionConfig.'
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class Config:
|
||||
"""Base configuration."""
|
||||
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||
'DATABASE_URL',
|
||||
'mysql+pymysql://root:password@localhost:3306/shopdb_flask',
|
||||
)
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'pool_pre_ping': True,
|
||||
'pool_recycle': 300,
|
||||
# Pin the client connection to utf8mb4 so reads/writes match the
|
||||
# utf8mb4 schema regardless of the server default charset. TestingConfig
|
||||
# (SQLite) replaces this whole dict, so the charset arg never reaches it.
|
||||
'connect_args': {'charset': 'utf8mb4'},
|
||||
}
|
||||
|
||||
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production')
|
||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(
|
||||
seconds=int(os.environ.get('JWT_ACCESS_TOKEN_EXPIRES', 3600))
|
||||
)
|
||||
JWT_REFRESH_TOKEN_EXPIRES = timedelta(
|
||||
seconds=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 2592000))
|
||||
)
|
||||
|
||||
CORS_ORIGINS = [
|
||||
origin.strip()
|
||||
for origin in os.environ.get('CORS_ORIGINS', 'http://localhost:5173').split(',')
|
||||
if origin.strip()
|
||||
]
|
||||
|
||||
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
|
||||
|
||||
# API key for the unattended PowerShell collector scripts
|
||||
COLLECTOR_API_KEY = os.environ.get('COLLECTOR_API_KEY', '')
|
||||
|
||||
ZABBIX_ENABLED = os.environ.get('ZABBIX_ENABLED', 'false').lower() == 'true'
|
||||
ZABBIX_URL = os.environ.get('ZABBIX_URL', '')
|
||||
ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '')
|
||||
|
||||
# Read-only HR/employee directory database (separate from the app DB).
|
||||
# Credentials come from the environment; never hardcode them in source.
|
||||
# No safe default for the password: unset means empty, the connection
|
||||
# fails loud rather than silently trying a guessed credential.
|
||||
EMPLOYEE_DB_HOST = os.environ.get('EMPLOYEE_DB_HOST', 'localhost')
|
||||
EMPLOYEE_DB_USER = os.environ.get('EMPLOYEE_DB_USER', '')
|
||||
EMPLOYEE_DB_PASSWORD = os.environ.get('EMPLOYEE_DB_PASSWORD', '')
|
||||
EMPLOYEE_DB_NAME = os.environ.get('EMPLOYEE_DB_NAME', 'wjf_employees')
|
||||
|
||||
# Read-write CMMC USB check-in/out database (separate from the app DB).
|
||||
# Credentials come from the environment; never hardcode them in source.
|
||||
# No safe default for the password: unset means empty, the connection
|
||||
# fails loud rather than silently trying a guessed credential.
|
||||
CMMC_USB_DB_HOST = os.environ.get('CMMC_USB_DB_HOST', 'localhost')
|
||||
CMMC_USB_DB_USER = os.environ.get('CMMC_USB_DB_USER', '')
|
||||
CMMC_USB_DB_PASSWORD = os.environ.get('CMMC_USB_DB_PASSWORD', '')
|
||||
CMMC_USB_DB_NAME = os.environ.get('CMMC_USB_DB_NAME', 'cmmc_usb')
|
||||
|
||||
CACHE_TYPE = 'SimpleCache'
|
||||
CACHE_DEFAULT_TIMEOUT = 600
|
||||
|
||||
# IP-based login rate limit (fixed window). Defense in depth atop the
|
||||
# per-account lockout. Backed by the existing cache extension.
|
||||
AUTH_RATELIMIT_ENABLED = os.environ.get(
|
||||
'AUTH_RATELIMIT_ENABLED', 'true').lower() == 'true'
|
||||
AUTH_RATELIMIT_MAX = int(os.environ.get('AUTH_RATELIMIT_MAX', 30))
|
||||
AUTH_RATELIMIT_WINDOW_SECONDS = int(
|
||||
os.environ.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300))
|
||||
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development configuration."""
|
||||
|
||||
DEBUG = True
|
||||
SQLALCHEMY_ECHO = True
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||
'DATABASE_URL',
|
||||
'mysql+pymysql://root:changeme@127.0.0.1:3306/shopdb_flask',
|
||||
)
|
||||
|
||||
|
||||
class TestingConfig(Config):
|
||||
"""Testing configuration."""
|
||||
|
||||
TESTING = True
|
||||
# Off by default so login-heavy fixtures do not trip the limiter; tests
|
||||
# that exercise rate limiting flip it on via app.config override.
|
||||
AUTH_RATELIMIT_ENABLED = False
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'connect_args': {'check_same_thread': False},
|
||||
'poolclass': StaticPool,
|
||||
}
|
||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(seconds=5)
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Production configuration.
|
||||
|
||||
Validation is deferred to validate() so that importing this class in a
|
||||
non-production environment (tests, dev, tooling) does not raise.
|
||||
create_app() invokes validate() when config_name == 'production' so
|
||||
a misconfigured production deploy still fails loud at boot.
|
||||
"""
|
||||
|
||||
DEBUG = False
|
||||
SQLALCHEMY_ECHO = False
|
||||
|
||||
JWT_COOKIE_SECURE = True
|
||||
JWT_COOKIE_CSRF_PROTECT = True
|
||||
|
||||
@classmethod
|
||||
def validate(cls):
|
||||
"""Verify production config is safe. Called from create_app."""
|
||||
secret_key = os.environ.get('SECRET_KEY', '')
|
||||
jwt_secret = os.environ.get('JWT_SECRET_KEY', '')
|
||||
database_url = os.environ.get('DATABASE_URL', '')
|
||||
cors_raw = os.environ.get('CORS_ORIGINS', '').strip()
|
||||
|
||||
insecure_defaults = {
|
||||
'dev-secret-key-change-in-production',
|
||||
'jwt-secret-key-change-in-production',
|
||||
}
|
||||
|
||||
if not secret_key or secret_key in insecure_defaults:
|
||||
raise ConfigError(
|
||||
'SECRET_KEY is required in production and must not be the '
|
||||
'development default. Set a strong random value in the '
|
||||
'environment before starting the app.'
|
||||
)
|
||||
if not jwt_secret or jwt_secret in insecure_defaults:
|
||||
raise ConfigError(
|
||||
'JWT_SECRET_KEY is required in production and must not be '
|
||||
'the development default. Set a strong random value in the '
|
||||
'environment before starting the app.'
|
||||
)
|
||||
if not database_url:
|
||||
raise ConfigError(
|
||||
'DATABASE_URL is required in production. No fallback to a '
|
||||
'development localhost URL is permitted.'
|
||||
)
|
||||
if not cors_raw or cors_raw == '*':
|
||||
raise ConfigError(
|
||||
'CORS_ORIGINS must be a comma-separated allowlist of '
|
||||
'explicit origins in production. Wildcard "*" is not '
|
||||
'permitted. Example: '
|
||||
'CORS_ORIGINS=https://shopdb.example.com,https://shopdb-mirror.example.com'
|
||||
)
|
||||
|
||||
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'testing': TestingConfig,
|
||||
'production': ProductionConfig,
|
||||
'default': DevelopmentConfig,
|
||||
}
|
||||
1
shopdb/core/__init__.py
Normal file
1
shopdb/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core module - always loaded."""
|
||||
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')
|
||||
70
shopdb/core/models/__init__.py
Normal file
70
shopdb/core/models/__init__.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Core SQLAlchemy models."""
|
||||
|
||||
from .base import BaseModel, SoftDeleteMixin, AuditMixin
|
||||
from .asset import Asset, AssetType, AssetStatus
|
||||
from .modeltype import ModelType
|
||||
from .vendor import Vendor
|
||||
from .model import Model
|
||||
from .businessunit import BusinessUnit
|
||||
from .dashboarddefault import DashboardDefault
|
||||
from .location import Location, LocationType, derive_locationcode
|
||||
from .operatingsystem import OperatingSystem
|
||||
from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
|
||||
from .communication import Communication, CommunicationType
|
||||
from .user import User, Role, Permission, full_permission_catalog
|
||||
from .application import Application, AppVersion
|
||||
from .supportteam import SupportTeam, SupportTeamContact
|
||||
from .setting import Setting
|
||||
from .auditlog import AuditLog
|
||||
from .customfield import CustomField, CustomFieldValue
|
||||
from .apitoken import ApiToken
|
||||
|
||||
__all__ = [
|
||||
# Base
|
||||
'BaseModel',
|
||||
'SoftDeleteMixin',
|
||||
'AuditMixin',
|
||||
# Asset (new architecture)
|
||||
'Asset',
|
||||
'AssetType',
|
||||
'AssetStatus',
|
||||
# Model-type lookup (referenced by models.modeltypeid)
|
||||
'ModelType',
|
||||
# Reference
|
||||
'Vendor',
|
||||
'Model',
|
||||
'BusinessUnit',
|
||||
'DashboardDefault',
|
||||
'Location',
|
||||
'LocationType',
|
||||
'derive_locationcode',
|
||||
'OperatingSystem',
|
||||
# Relationships
|
||||
'AssetRelationship',
|
||||
'RelationshipType',
|
||||
'RelationshipTypePropagation',
|
||||
# Communication
|
||||
'Communication',
|
||||
'CommunicationType',
|
||||
# Auth
|
||||
'User',
|
||||
'Role',
|
||||
'Permission',
|
||||
'full_permission_catalog',
|
||||
# Applications
|
||||
'Application',
|
||||
'AppVersion',
|
||||
# Support teams
|
||||
'SupportTeam',
|
||||
'SupportTeamContact',
|
||||
# Knowledge Base
|
||||
# Settings
|
||||
'Setting',
|
||||
# Audit
|
||||
'AuditLog',
|
||||
# Custom fields
|
||||
'CustomField',
|
||||
'CustomFieldValue',
|
||||
# Personal API tokens
|
||||
'ApiToken',
|
||||
]
|
||||
128
shopdb/core/models/apitoken.py
Normal file
128
shopdb/core/models/apitoken.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Personal API token model.
|
||||
|
||||
A personal API token (PAT) lets a script or integration authenticate as a
|
||||
user without the hourly-expiring login JWT. The secret is shown ONCE at
|
||||
creation; only its sha256 hash is stored. By default the token acts as its
|
||||
owning user, so the existing role/permission decorators authorize it unchanged.
|
||||
A token MAY optionally carry a scopes list (see scopes/scopelist): a scoped
|
||||
token grants only the listed permissions, intersected with what the owner holds,
|
||||
and suspends the admin bypass. See shopdb/utils/authz.py for the enforcement.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
def _utcnow():
|
||||
# naive UTC to match the other DB DateTime columns (stored without tzinfo)
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
# Wire label on the full secret. Scripts send "Authorization: Bearer <secret>".
|
||||
TOKEN_SECRET_PREFIX = 'shopdb_pat_'
|
||||
# Hex chars of randomness after the label (secrets.token_hex(20) => 40 hex).
|
||||
_TOKEN_RANDOM_BYTES = 20
|
||||
# How many leading random-hex chars we keep in the clear for display/lookup.
|
||||
_TOKEN_PREFIX_LEN = 8
|
||||
|
||||
|
||||
class ApiToken(BaseModel):
|
||||
"""Personal API token. Stores only the hash of the secret."""
|
||||
__tablename__ = 'apitokens'
|
||||
|
||||
tokenid = db.Column(db.Integer, primary_key=True)
|
||||
# The token acts as this user; NOT NULL so authz always has a principal.
|
||||
userid = db.Column(db.Integer, db.ForeignKey('users.userid'),
|
||||
nullable=False, index=True)
|
||||
# What the token is for (e.g. "legacy import runner").
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
# First few random-hex chars, kept clear so a user can tell tokens apart.
|
||||
tokenprefix = db.Column(db.String(16), nullable=True, index=True)
|
||||
# sha256 hex of the full secret. Unique so a hash lookup finds one row.
|
||||
tokenhash = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
# Null expiresat means the token never expires.
|
||||
expiresat = db.Column(db.DateTime, nullable=True)
|
||||
# Last time the token authenticated a request (throttled write).
|
||||
lastusedat = db.Column(db.DateTime, nullable=True)
|
||||
# JSON array of permission-name strings. NULL = unscoped (full owner
|
||||
# authority). A scoped token grants ONLY these, intersected with what the
|
||||
# owner holds, and suspends the admin bypass. See scopelist below.
|
||||
scopes = db.Column(db.Text, nullable=True)
|
||||
|
||||
user = db.relationship('User', backref=db.backref('apitokens', lazy='dynamic'))
|
||||
|
||||
@staticmethod
|
||||
def generate_secret() -> str:
|
||||
"""Return a fresh full secret: shopdb_pat_<40 hex>. Never stored."""
|
||||
return TOKEN_SECRET_PREFIX + secrets.token_hex(_TOKEN_RANDOM_BYTES)
|
||||
|
||||
@staticmethod
|
||||
def hash_secret(secret: str) -> str:
|
||||
"""sha256 hex of the full secret. The token has 160 bits of entropy,
|
||||
so a plain hash lookup (not a slow password hash) is appropriate."""
|
||||
return hashlib.sha256(secret.encode('utf-8')).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def prefix_of(secret: str) -> str:
|
||||
"""The clear display prefix (leading random-hex chars) of a secret."""
|
||||
randompart = secret[len(TOKEN_SECRET_PREFIX):]
|
||||
return randompart[:_TOKEN_PREFIX_LEN]
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""True when expiresat is set and in the past."""
|
||||
return self.expiresat is not None and self.expiresat < _utcnow()
|
||||
|
||||
@property
|
||||
def scopelist(self):
|
||||
"""Parsed scope names as a list, or None when the token is unscoped."""
|
||||
if self.scopes is None:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(self.scopes)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return value if isinstance(value, list) else None
|
||||
|
||||
@scopelist.setter
|
||||
def scopelist(self, names):
|
||||
"""Store a scope list, or None to clear scoping. Does NOT validate the
|
||||
names; callers reject bad input first via unknown_scope_names."""
|
||||
if names is None:
|
||||
self.scopes = None
|
||||
else:
|
||||
self.scopes = json.dumps(list(names))
|
||||
|
||||
@staticmethod
|
||||
def unknown_scope_names(names) -> list:
|
||||
"""Return the subset of names that are not in the permission catalog.
|
||||
|
||||
The catalog is core plus every ENABLED plugin's permissions, so a scope
|
||||
naming a disabled plugin's permission is treated as unknown."""
|
||||
from shopdb.core.models.user import full_permission_catalog
|
||||
known = {name for name, _desc, _cat in full_permission_catalog()}
|
||||
return [n for n in names if n not in known]
|
||||
|
||||
def to_dict(self, include_owner: bool = False) -> dict:
|
||||
"""Serialize for the API. NEVER includes the hash or the secret."""
|
||||
result = {
|
||||
'tokenid': self.tokenid,
|
||||
'userid': self.userid,
|
||||
'name': self.name,
|
||||
'tokenprefix': self.tokenprefix,
|
||||
'displayprefix': f'{TOKEN_SECRET_PREFIX}{self.tokenprefix or ""}',
|
||||
'expiresat': self.expiresat.isoformat() + 'Z' if self.expiresat else None,
|
||||
'lastusedat': self.lastusedat.isoformat() + 'Z' if self.lastusedat else None,
|
||||
'scopes': self.scopelist,
|
||||
'isactive': self.isactive,
|
||||
'isexpired': self.is_expired,
|
||||
'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
||||
}
|
||||
if include_owner:
|
||||
result['username'] = self.user.username if self.user else None
|
||||
return result
|
||||
95
shopdb/core/models/application.py
Normal file
95
shopdb/core/models/application.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Application tracking models."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
def _utcnow():
|
||||
# naive UTC to match the other DB DateTime columns (stored without tzinfo)
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
# SupportTeam / SupportTeamContact live in supportteam.py; imported by the
|
||||
# models package so the Application.supportteam relationship resolves.
|
||||
|
||||
|
||||
class Application(BaseModel):
|
||||
"""Application catalog."""
|
||||
__tablename__ = 'applications'
|
||||
|
||||
appid = db.Column(db.Integer, primary_key=True)
|
||||
appname = db.Column(db.String(100), unique=True, nullable=False)
|
||||
appdescription = db.Column(db.String(255))
|
||||
supportteamid = db.Column(db.Integer, db.ForeignKey('supportteams.supportteamid'))
|
||||
isinstallable = db.Column(db.Boolean, default=False)
|
||||
applicationnotes = db.Column(db.Text)
|
||||
installpath = db.Column(db.String(255))
|
||||
applicationlink = db.Column(db.String(512))
|
||||
documentationpath = db.Column(db.String(512))
|
||||
ishidden = db.Column(db.Boolean, default=False)
|
||||
isprinter = db.Column(db.Boolean, default=False)
|
||||
islicenced = db.Column(db.Boolean, default=False)
|
||||
isrequired = db.Column(
|
||||
db.Boolean, default=False,
|
||||
comment='Required on all PCs (drives the software-compliance report)'
|
||||
)
|
||||
image = db.Column(db.String(255))
|
||||
|
||||
# Relationships
|
||||
supportteam = db.relationship('SupportTeam', back_populates='applications')
|
||||
versions = db.relationship('AppVersion', back_populates='application', lazy='dynamic')
|
||||
|
||||
def to_dict(self):
|
||||
"""Serialize, flattening the support team + its active contacts.
|
||||
|
||||
Emits supportteamname, teamurl, and the team's active contacts so the
|
||||
frontend needs a single call to render the Support card.
|
||||
"""
|
||||
result = super().to_dict()
|
||||
team = self.supportteam
|
||||
result['supportteamname'] = team.teamname if team else None
|
||||
result['teamurl'] = team.teamurl if team else None
|
||||
result['contacts'] = [
|
||||
{'name': c.name, 'sso': c.sso}
|
||||
for c in team.active_contacts()
|
||||
] if team else []
|
||||
return result
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Application {self.appname}>"
|
||||
|
||||
|
||||
class AppVersion(db.Model):
|
||||
"""Application version tracking."""
|
||||
__tablename__ = 'appversions'
|
||||
|
||||
appversionid = db.Column(db.Integer, primary_key=True)
|
||||
appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False)
|
||||
version = db.Column(db.String(50), nullable=False)
|
||||
releasedate = db.Column(db.Date)
|
||||
notes = db.Column(db.String(255))
|
||||
dateadded = db.Column(db.DateTime, default=_utcnow)
|
||||
isactive = db.Column(db.Boolean, default=True)
|
||||
|
||||
# Relationships
|
||||
application = db.relationship('Application', back_populates='versions')
|
||||
|
||||
# Unique constraint on app + version
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('appid', 'version', name='uq_app_version'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
'appversionid': self.appversionid,
|
||||
'appid': self.appid,
|
||||
'version': self.version,
|
||||
'releasedate': self.releasedate.isoformat() if self.releasedate else None,
|
||||
'notes': self.notes,
|
||||
'dateadded': self.dateadded.isoformat() + 'Z' if self.dateadded else None,
|
||||
'isactive': self.isactive
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AppVersion {self.application.appname if self.application else self.appid} v{self.version}>"
|
||||
285
shopdb/core/models/asset.py
Normal file
285
shopdb/core/models/asset.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""Polymorphic Asset models - core of the new asset architecture."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel, SoftDeleteMixin, AuditMixin
|
||||
|
||||
|
||||
class AssetType(BaseModel):
|
||||
"""
|
||||
Registry of asset categories.
|
||||
|
||||
Each type maps to a plugin-owned extension table.
|
||||
Examples: machine, computer, network_device, printer
|
||||
"""
|
||||
__tablename__ = 'assettypes'
|
||||
|
||||
assettypeid = db.Column(db.Integer, primary_key=True)
|
||||
assettype = db.Column(
|
||||
db.String(50),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
comment='Category name: machine, computer, network_device, printer'
|
||||
)
|
||||
pluginname = db.Column(
|
||||
db.String(100),
|
||||
nullable=True,
|
||||
comment='Plugin that owns this type'
|
||||
)
|
||||
tablename = db.Column(
|
||||
db.String(100),
|
||||
nullable=True,
|
||||
comment='Extension table name for this type'
|
||||
)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AssetType {self.assettype}>"
|
||||
|
||||
|
||||
class AssetStatus(BaseModel):
|
||||
"""Asset status options."""
|
||||
__tablename__ = 'assetstatuses'
|
||||
|
||||
statusid = db.Column(db.Integer, primary_key=True)
|
||||
status = db.Column(db.String(50), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
color = db.Column(db.String(20), comment='CSS color for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AssetStatus {self.status}>"
|
||||
|
||||
|
||||
class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
||||
"""
|
||||
Core asset model - minimal shared fields.
|
||||
|
||||
Category-specific data lives in plugin extension tables
|
||||
(machines, computers, network_devices, printers).
|
||||
The assetid matches original machineid for migration compatibility.
|
||||
"""
|
||||
__tablename__ = 'assets'
|
||||
|
||||
assetid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Identification
|
||||
assetnumber = db.Column(
|
||||
db.String(50),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'
|
||||
)
|
||||
name = db.Column(
|
||||
db.String(100),
|
||||
comment='Display name/alias'
|
||||
)
|
||||
gaugelabreference = db.Column(
|
||||
db.String(50),
|
||||
index=True,
|
||||
comment='Gauge lab asset reference (authoritative tag the gauge lab '
|
||||
'assigns to machines); distinct from assetnumber'
|
||||
)
|
||||
maintenancereference = db.Column(
|
||||
db.String(50),
|
||||
index=True,
|
||||
comment='Maintenance system asset reference; distinct from assetnumber'
|
||||
)
|
||||
serialnumber = db.Column(
|
||||
db.String(100),
|
||||
index=True,
|
||||
comment='Hardware serial number'
|
||||
)
|
||||
|
||||
# Classification
|
||||
assettypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assettypes.assettypeid'),
|
||||
nullable=False
|
||||
)
|
||||
statusid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assetstatuses.statusid'),
|
||||
default=1,
|
||||
comment='In Use, Spare, Retired, etc.'
|
||||
)
|
||||
|
||||
# Location and organization
|
||||
locationid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('locations.locationid'),
|
||||
nullable=True
|
||||
)
|
||||
businessunitid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('businessunits.businessunitid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Floor map position (ADR-001: asset-specific override; nullable)
|
||||
mapx = db.Column(db.Integer, comment='X coordinate on floor map (ADR-001)')
|
||||
mapy = db.Column(db.Integer, comment='Y coordinate on floor map (ADR-001)')
|
||||
|
||||
# Notes
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
assettype = db.relationship('AssetType', backref='assets')
|
||||
status = db.relationship('AssetStatus', backref='assets')
|
||||
location = db.relationship('Location', backref='assets')
|
||||
businessunit = db.relationship('BusinessUnit', backref='assets')
|
||||
|
||||
# Communications (one-to-many) - will be migrated to use assetid
|
||||
communications = db.relationship(
|
||||
'Communication',
|
||||
foreign_keys='Communication.assetid',
|
||||
backref='asset',
|
||||
cascade='all, delete-orphan',
|
||||
lazy='dynamic'
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
db.Index('idx_asset_type_bu', 'assettypeid', 'businessunitid'),
|
||||
db.Index('idx_asset_location', 'locationid'),
|
||||
db.Index('idx_asset_active', 'isactive'),
|
||||
db.Index('idx_asset_status', 'statusid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Asset {self.assetnumber}>"
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Get display name (name if set, otherwise assetnumber)."""
|
||||
return self.name or self.assetnumber
|
||||
|
||||
@property
|
||||
def primary_ip(self):
|
||||
"""Get primary IP address from communications."""
|
||||
comm = self.communications.filter_by(
|
||||
isprimary=True,
|
||||
comtypeid=1 # IP type
|
||||
).first()
|
||||
if comm:
|
||||
return comm.ipaddress
|
||||
# Fall back to any IP
|
||||
comm = self.communications.filter_by(comtypeid=1).first()
|
||||
return comm.ipaddress if comm else None
|
||||
|
||||
def get_inherited_location(self):
|
||||
"""
|
||||
Get location data from a related asset if this asset has none.
|
||||
|
||||
Returns dict with locationid, location_name, mapx, mapy, and
|
||||
inherited_from (assetnumber of source asset) if location was inherited.
|
||||
Returns None if no location data available.
|
||||
"""
|
||||
if self.locationid is not None or (self.mapx is not None and self.mapy is not None):
|
||||
return None
|
||||
|
||||
related_assets = []
|
||||
|
||||
if hasattr(self, 'incoming_relationships'):
|
||||
for rel in self.incoming_relationships:
|
||||
if rel.sourceasset and rel.isactive:
|
||||
related_assets.append(rel.sourceasset)
|
||||
|
||||
if hasattr(self, 'outgoing_relationships'):
|
||||
for rel in self.outgoing_relationships:
|
||||
if rel.targetasset and rel.isactive:
|
||||
related_assets.append(rel.targetasset)
|
||||
|
||||
for related in related_assets:
|
||||
if related.locationid is not None or (related.mapx is not None and related.mapy is not None):
|
||||
return {
|
||||
'locationid': related.locationid,
|
||||
'locationname': related.location.locationname if related.location else None,
|
||||
'mapx': related.mapx,
|
||||
'mapy': related.mapy,
|
||||
'inheritedfrom': related.assetnumber
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def to_dict(self, include_type_data=False, include_inherited_location=True):
|
||||
"""
|
||||
Convert model to dictionary.
|
||||
|
||||
Args:
|
||||
include_type_data: If True, include category-specific data from extension table
|
||||
include_inherited_location: If True, include location from related assets when missing
|
||||
"""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names for convenience
|
||||
if self.assettype:
|
||||
result['assettypename'] = self.assettype.assettype
|
||||
result['assettypecolor'] = getattr(self.assettype, 'color', None)
|
||||
if self.status:
|
||||
result['statusname'] = self.status.status
|
||||
result['statuscolor'] = self.status.color
|
||||
if self.location:
|
||||
result['locationname'] = self.location.locationname
|
||||
if self.businessunit:
|
||||
result['businessunitname'] = self.businessunit.businessunit
|
||||
|
||||
# Add plugin-specific ID for navigation purposes
|
||||
if hasattr(self, 'machine') and self.machine:
|
||||
result['pluginid'] = self.machine.machineid
|
||||
elif hasattr(self, 'computer') and self.computer:
|
||||
result['pluginid'] = self.computer.computerid
|
||||
elif hasattr(self, 'network_device') and self.network_device:
|
||||
result['pluginid'] = self.network_device.networkdeviceid
|
||||
elif hasattr(self, 'printer') and self.printer:
|
||||
result['pluginid'] = self.printer.printerid
|
||||
elif hasattr(self, 'measuringtool') and self.measuringtool:
|
||||
result['pluginid'] = self.measuringtool.measuringtoolid
|
||||
|
||||
# Include inherited location if this asset has no location data
|
||||
if include_inherited_location:
|
||||
inherited = self.get_inherited_location()
|
||||
if inherited:
|
||||
result['inheritedlocation'] = inherited
|
||||
# Also set the location fields if they're missing
|
||||
if result.get('locationid') is None:
|
||||
result['locationid'] = inherited['locationid']
|
||||
result['locationname'] = inherited['locationname']
|
||||
if result.get('mapx') is None:
|
||||
result['mapx'] = inherited['mapx']
|
||||
if result.get('mapy') is None:
|
||||
result['mapy'] = inherited['mapy']
|
||||
|
||||
# Operation/short code of the resolved location (own or inherited).
|
||||
# Derived from the location name's leading token; labels can encode a
|
||||
# tool's inspection operation instead of the tool. None when unplaced.
|
||||
from .location import derive_locationcode
|
||||
result['locationcode'] = derive_locationcode(result.get('locationname'))
|
||||
|
||||
# Include extension data if requested
|
||||
if include_type_data:
|
||||
ext_data = self._get_extension_data()
|
||||
if ext_data:
|
||||
result['typedata'] = ext_data
|
||||
|
||||
return result
|
||||
|
||||
def _get_extension_data(self):
|
||||
"""Get category-specific data from extension table."""
|
||||
# Check for machine extension
|
||||
if hasattr(self, 'machine') and self.machine:
|
||||
return self.machine.to_dict()
|
||||
# Check for computer extension
|
||||
if hasattr(self, 'computer') and self.computer:
|
||||
return self.computer.to_dict()
|
||||
# Check for network_device extension
|
||||
if hasattr(self, 'network_device') and self.network_device:
|
||||
return self.network_device.to_dict()
|
||||
# Check for printer extension
|
||||
if hasattr(self, 'printer') and self.printer:
|
||||
return self.printer.to_dict()
|
||||
# Check for measuring-tool extension
|
||||
if hasattr(self, 'measuringtool') and self.measuringtool:
|
||||
return self.measuringtool.to_dict()
|
||||
return None
|
||||
160
shopdb/core/models/auditlog.py
Normal file
160
shopdb/core/models/auditlog.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Audit log model for tracking changes."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from shopdb.extensions import db
|
||||
|
||||
def _utcnow():
|
||||
# naive UTC for DB columns (stored without tzinfo)
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class AuditLog(db.Model):
|
||||
"""
|
||||
Audit log for tracking user actions.
|
||||
|
||||
Records who did what, when, from where, and what changed.
|
||||
"""
|
||||
__tablename__ = 'auditlogs'
|
||||
|
||||
auditlogid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Who
|
||||
userid = db.Column(db.Integer, db.ForeignKey('users.userid'), nullable=True)
|
||||
username = db.Column(db.String(100), nullable=True) # Denormalized for history
|
||||
|
||||
# When
|
||||
timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False, index=True)
|
||||
|
||||
# Where (client info)
|
||||
ipaddress = db.Column(db.String(45), nullable=True) # IPv6 max length
|
||||
useragent = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# What
|
||||
action = db.Column(db.String(20), nullable=False, index=True) # created, updated, deleted
|
||||
entitytype = db.Column(db.String(50), nullable=False, index=True) # Asset, Printer, Setting, etc.
|
||||
entityid = db.Column(db.Integer, nullable=True) # ID of the affected record
|
||||
entityname = db.Column(db.String(255), nullable=True) # Human-readable identifier
|
||||
|
||||
# Changes (stored as JSON text)
|
||||
_changes = db.Column('changes', db.Text, nullable=True) # {"field": {"old": x, "new": y}, ...}
|
||||
|
||||
@property
|
||||
def changes(self):
|
||||
"""Get changes as dict."""
|
||||
if self._changes:
|
||||
import json
|
||||
try:
|
||||
return json.loads(self._changes)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
@changes.setter
|
||||
def changes(self, value):
|
||||
"""Set changes from dict."""
|
||||
if value is not None:
|
||||
import json
|
||||
self._changes = json.dumps(value)
|
||||
else:
|
||||
self._changes = None
|
||||
|
||||
# Additional context
|
||||
details = db.Column(db.Text, nullable=True) # Optional description
|
||||
|
||||
# Relationship
|
||||
user = db.relationship('User', backref=db.backref('auditlogs', lazy='dynamic'))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'auditlogid': self.auditlogid,
|
||||
'userid': self.userid,
|
||||
'username': self.username,
|
||||
'timestamp': self.timestamp.isoformat() + 'Z' if self.timestamp else None,
|
||||
'ipaddress': self.ipaddress,
|
||||
'useragent': self.useragent,
|
||||
'action': self.action,
|
||||
'entitytype': self.entitytype,
|
||||
'entityid': self.entityid,
|
||||
'entityname': self.entityname,
|
||||
'changes': self.changes,
|
||||
'details': self.details
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def log(cls, action: str, entitytype: str, entityid: int = None,
|
||||
entityname: str = None, changes: dict = None, details: str = None,
|
||||
user=None, request=None):
|
||||
"""
|
||||
Create an audit log entry.
|
||||
|
||||
Args:
|
||||
action: 'created', 'updated', 'deleted'
|
||||
entitytype: Type of entity (e.g., 'Asset', 'Printer', 'Setting')
|
||||
entityid: ID of the affected record
|
||||
entityname: Human-readable name/identifier
|
||||
changes: Dict of field changes {"field": {"old": x, "new": y}}
|
||||
details: Optional description
|
||||
user: Current user object (or will try to get from flask-jwt-extended)
|
||||
request: Flask request object (or will try to get current request)
|
||||
"""
|
||||
from flask import request as flask_request
|
||||
from flask_jwt_extended import current_user, verify_jwt_in_request
|
||||
|
||||
# Get user info
|
||||
if user is None:
|
||||
try:
|
||||
verify_jwt_in_request(optional=True)
|
||||
user = current_user
|
||||
except:
|
||||
pass
|
||||
|
||||
userid = user.userid if user else None
|
||||
username = user.username if user else None
|
||||
|
||||
# Get request info
|
||||
request_obj = request or flask_request
|
||||
ipaddress = None
|
||||
useragent = None
|
||||
|
||||
if request_obj:
|
||||
# Handle proxy forwarding
|
||||
ipaddress = request_obj.headers.get('X-Forwarded-For', request_obj.remote_addr)
|
||||
if ipaddress and ',' in ipaddress:
|
||||
ipaddress = ipaddress.split(',')[0].strip()
|
||||
useragent = request_obj.headers.get('User-Agent', '')[:255]
|
||||
|
||||
entry = cls(
|
||||
userid=userid,
|
||||
username=username,
|
||||
ipaddress=ipaddress,
|
||||
useragent=useragent,
|
||||
action=action,
|
||||
entitytype=entitytype,
|
||||
entityid=entityid,
|
||||
entityname=entityname,
|
||||
changes=changes,
|
||||
details=details
|
||||
)
|
||||
|
||||
db.session.add(entry)
|
||||
# Don't commit here - let the caller handle transaction
|
||||
return entry
|
||||
|
||||
@classmethod
|
||||
def log_create(cls, entitytype: str, entity, name_field: str = 'name'):
|
||||
"""Log a create action."""
|
||||
entityname = getattr(entity, name_field, None) or str(entity)
|
||||
entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
|
||||
return cls.log('created', entitytype, entityid=entityid, entityname=entityname)
|
||||
|
||||
@classmethod
|
||||
def log_update(cls, entitytype: str, entity, changes: dict, name_field: str = 'name'):
|
||||
"""Log an update action with changes."""
|
||||
entityname = getattr(entity, name_field, None) or str(entity)
|
||||
entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
|
||||
return cls.log('updated', entitytype, entityid=entityid, entityname=entityname, changes=changes)
|
||||
|
||||
@classmethod
|
||||
def log_delete(cls, entitytype: str, entityid: int, entityname: str = None):
|
||||
"""Log a delete action."""
|
||||
return cls.log('deleted', entitytype, entityid=entityid, entityname=entityname)
|
||||
71
shopdb/core/models/base.py
Normal file
71
shopdb/core/models/base.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Base model class with common fields."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from shopdb.extensions import db
|
||||
|
||||
|
||||
def _utcnow():
|
||||
# naive UTC for DB columns (stored without tzinfo)
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class BaseModel(db.Model):
|
||||
"""
|
||||
Abstract base model with common fields.
|
||||
All models should inherit from this.
|
||||
"""
|
||||
__abstract__ = True
|
||||
|
||||
createddate = db.Column(
|
||||
db.DateTime,
|
||||
default=_utcnow,
|
||||
nullable=False
|
||||
)
|
||||
modifieddate = db.Column(
|
||||
db.DateTime,
|
||||
default=_utcnow,
|
||||
onupdate=_utcnow,
|
||||
nullable=False
|
||||
)
|
||||
isactive = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert model to dictionary."""
|
||||
result = {}
|
||||
for c in self.__table__.columns:
|
||||
value = getattr(self, c.name)
|
||||
if isinstance(value, datetime):
|
||||
value = value.isoformat() + 'Z'
|
||||
result[c.name] = value
|
||||
return result
|
||||
|
||||
def update(self, **kwargs):
|
||||
"""Update model attributes."""
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
|
||||
@classmethod
|
||||
def get_active(cls):
|
||||
"""Return query for active records only."""
|
||||
return cls.query.filter_by(isactive=True)
|
||||
|
||||
|
||||
class SoftDeleteMixin:
|
||||
"""Mixin for soft delete functionality."""
|
||||
|
||||
deleteddate = db.Column(db.DateTime, nullable=True)
|
||||
deletedby = db.Column(db.String(100), nullable=True)
|
||||
|
||||
def soft_delete(self, deleted_by: str = None):
|
||||
"""Mark record as deleted."""
|
||||
self.isactive = False
|
||||
self.deleteddate = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
self.deletedby = deleted_by
|
||||
|
||||
|
||||
class AuditMixin:
|
||||
"""Mixin for audit fields."""
|
||||
|
||||
createdby = db.Column(db.String(100), nullable=True)
|
||||
modifiedby = db.Column(db.String(100), nullable=True)
|
||||
31
shopdb/core/models/businessunit.py
Normal file
31
shopdb/core/models/businessunit.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Business Unit model."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class BusinessUnit(BaseModel):
|
||||
"""Business unit / department model."""
|
||||
__tablename__ = 'businessunits'
|
||||
|
||||
businessunitid = db.Column(db.Integer, primary_key=True)
|
||||
businessunit = db.Column(db.String(100), unique=True, nullable=False)
|
||||
code = db.Column(db.String(20), unique=True, comment='Short code')
|
||||
description = db.Column(db.Text)
|
||||
|
||||
# Optional parent for hierarchy
|
||||
parentid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('businessunits.businessunitid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Self-referential relationship for hierarchy
|
||||
parent = db.relationship(
|
||||
'BusinessUnit',
|
||||
remote_side=[businessunitid],
|
||||
backref='children'
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<BusinessUnit {self.businessunit}>"
|
||||
93
shopdb/core/models/communication.py
Normal file
93
shopdb/core/models/communication.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Communication/network interface models."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class CommunicationType(BaseModel):
|
||||
"""Types of communication interfaces."""
|
||||
__tablename__ = 'communicationtypes'
|
||||
|
||||
comtypeid = db.Column(db.Integer, primary_key=True)
|
||||
comtype = db.Column(db.String(50), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
|
||||
# Types: IP, Serial, USB, VNC, FTP, DNC, Parallel, Network Interface
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CommunicationType {self.comtype}>"
|
||||
|
||||
|
||||
class Communication(BaseModel):
|
||||
"""
|
||||
Communication interface for an asset (or legacy machine).
|
||||
Stores network config, serial settings, etc.
|
||||
"""
|
||||
__tablename__ = 'communications'
|
||||
|
||||
communicationid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# New asset-based FK (preferred)
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid'),
|
||||
nullable=True,
|
||||
comment='FK to assets table (new architecture)'
|
||||
)
|
||||
|
||||
comtypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('communicationtypes.comtypeid'),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Network configuration (for IP type)
|
||||
ipaddress = db.Column(db.String(50))
|
||||
subnetmask = db.Column(db.String(50))
|
||||
gateway = db.Column(db.String(50))
|
||||
dns1 = db.Column(db.String(50))
|
||||
dns2 = db.Column(db.String(50))
|
||||
macaddress = db.Column(db.String(50))
|
||||
isdhcp = db.Column(db.Boolean, default=False)
|
||||
|
||||
# Serial configuration (for Serial type)
|
||||
comport = db.Column(db.String(20))
|
||||
baudrate = db.Column(db.Integer)
|
||||
databits = db.Column(db.Integer)
|
||||
stopbits = db.Column(db.String(10))
|
||||
parity = db.Column(db.String(20))
|
||||
flowcontrol = db.Column(db.String(20))
|
||||
|
||||
# VNC/FTP configuration
|
||||
port = db.Column(db.Integer)
|
||||
username = db.Column(db.String(100))
|
||||
# Note: passwords should not be stored here - use secure vault
|
||||
|
||||
# DNC configuration
|
||||
pathname = db.Column(db.String(255))
|
||||
pathname2 = db.Column(db.String(255), comment='Secondary path for dualpath')
|
||||
|
||||
# Flags
|
||||
isprimary = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Primary communication method'
|
||||
)
|
||||
ismachinenetwork = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='On machine network vs office network'
|
||||
)
|
||||
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
# Relationships
|
||||
comtype = db.relationship('CommunicationType', backref='communications')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_comm_asset', 'assetid'),
|
||||
db.Index('idx_comm_ip', 'ipaddress'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Communication {self.assetid}:{self.comtype.comtype if self.comtype else 'Unknown'}>"
|
||||
94
shopdb/core/models/customfield.py
Normal file
94
shopdb/core/models/customfield.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Custom fields: site-defined extra attributes per asset type.
|
||||
|
||||
A CustomField is a definition scoped to one asset type (machine, computer,
|
||||
printer, network_device). A CustomFieldValue holds one asset's value for one
|
||||
field. This is the generic form of the built-in identifier columns - sites add
|
||||
their own attributes without a schema change.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db
|
||||
|
||||
# Allowed datatypes for a custom field. Values are always stored as text and
|
||||
# cast on render/input by the frontend.
|
||||
CUSTOM_FIELD_DATATYPES = ('text', 'number', 'date', 'boolean', 'select')
|
||||
|
||||
|
||||
class CustomField(db.Model):
|
||||
__tablename__ = 'customfields'
|
||||
|
||||
fieldid = db.Column(db.Integer, primary_key=True)
|
||||
assettypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assettypes.assettypeid'),
|
||||
nullable=False,
|
||||
comment='Which asset category this field applies to'
|
||||
)
|
||||
# Machine-name key, unique per asset type. Used for stable references.
|
||||
fieldkey = db.Column(db.String(50), nullable=False)
|
||||
label = db.Column(db.String(150), nullable=False)
|
||||
datatype = db.Column(db.String(20), nullable=False, server_default='text')
|
||||
# JSON array of options, only meaningful when datatype='select'
|
||||
options = db.Column(db.Text)
|
||||
showondetail = db.Column(db.Boolean, nullable=False, server_default='1')
|
||||
showonform = db.Column(db.Boolean, nullable=False, server_default='1')
|
||||
sortorder = db.Column(db.Integer, nullable=False, server_default='0')
|
||||
isactive = db.Column(db.Boolean, nullable=False, server_default='1')
|
||||
# When true, this field's values are matched by global search.
|
||||
searchable = db.Column(db.Boolean, nullable=False, server_default='0')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('assettypeid', 'fieldkey', name='uq_customfield_type_key'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
import json
|
||||
parsed_options = []
|
||||
if self.options:
|
||||
try:
|
||||
parsed_options = json.loads(self.options)
|
||||
except (ValueError, TypeError):
|
||||
parsed_options = []
|
||||
return {
|
||||
'fieldid': self.fieldid,
|
||||
'assettypeid': self.assettypeid,
|
||||
'fieldkey': self.fieldkey,
|
||||
'label': self.label,
|
||||
'datatype': self.datatype,
|
||||
'options': parsed_options,
|
||||
'showondetail': bool(self.showondetail),
|
||||
'showonform': bool(self.showonform),
|
||||
'sortorder': self.sortorder,
|
||||
'isactive': bool(self.isactive),
|
||||
'searchable': bool(self.searchable),
|
||||
}
|
||||
|
||||
|
||||
class CustomFieldValue(db.Model):
|
||||
__tablename__ = 'customfieldvalues'
|
||||
|
||||
valueid = db.Column(db.Integer, primary_key=True)
|
||||
fieldid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('customfields.fieldid', ondelete='CASCADE'),
|
||||
nullable=False
|
||||
)
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False
|
||||
)
|
||||
value = db.Column(db.Text)
|
||||
|
||||
field = db.relationship('CustomField')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('fieldid', 'assetid', name='uq_customfieldvalue_field_asset'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'valueid': self.valueid,
|
||||
'fieldid': self.fieldid,
|
||||
'assetid': self.assetid,
|
||||
'value': self.value,
|
||||
}
|
||||
28
shopdb/core/models/dashboarddefault.py
Normal file
28
shopdb/core/models/dashboarddefault.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Dashboard default model: visitor-IP -> business-unit mapping.
|
||||
|
||||
A shopfloor / lobby kiosk display resolves which business unit to show by the
|
||||
display PC's IP address. Powers the visitor-location lookup the shopfloor and
|
||||
TV dashboards call when no explicit business unit is given.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class DashboardDefault(BaseModel):
|
||||
"""Maps a display PC IP address to the business unit it should show."""
|
||||
__tablename__ = 'dashboarddefaults'
|
||||
|
||||
dashboarddefaultid = db.Column(db.Integer, primary_key=True)
|
||||
ipaddress = db.Column(db.String(50), unique=True, nullable=False)
|
||||
businessunitid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('businessunits.businessunitid'),
|
||||
nullable=False
|
||||
)
|
||||
description = db.Column(db.String(255))
|
||||
|
||||
businessunit = db.relationship('BusinessUnit')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DashboardDefault {self.ipaddress} -> {self.businessunitid}>"
|
||||
91
shopdb/core/models/location.py
Normal file
91
shopdb/core/models/location.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Location + LocationType models."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
def derive_locationcode(locationname):
|
||||
"""Operation/short code for a location = leading token of its name.
|
||||
|
||||
Locations have no dedicated code column, so the operation code is the
|
||||
leading whitespace-delimited token of the location name. Example:
|
||||
'0615 Blisk Inspection' -> '0615'. Blank/None name -> None. Used by
|
||||
printed labels that encode a tool's inspection operation rather than the
|
||||
tool itself.
|
||||
"""
|
||||
if not locationname:
|
||||
return None
|
||||
parts = str(locationname).strip().split()
|
||||
return parts[0] if parts else None
|
||||
|
||||
|
||||
class LocationType(BaseModel):
|
||||
"""Location classification (ADR-001 shared reference data).
|
||||
|
||||
Seeded values: section, cell, subcell, operation, meetingroom, lab,
|
||||
office, storage, hallway, networkcloset, building. Sites can extend.
|
||||
"""
|
||||
__tablename__ = 'locationtypes'
|
||||
|
||||
locationtypeid = db.Column(db.Integer, primary_key=True)
|
||||
locationtype = db.Column(db.String(50), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
color = db.Column(db.String(20), comment='CSS color for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LocationType {self.locationtype}>"
|
||||
|
||||
|
||||
class Location(BaseModel):
|
||||
"""Physical location model."""
|
||||
__tablename__ = 'locations'
|
||||
|
||||
locationid = db.Column(db.Integer, primary_key=True)
|
||||
locationname = db.Column(db.String(100), unique=True, nullable=False)
|
||||
building = db.Column(db.String(100))
|
||||
floor = db.Column(db.String(50))
|
||||
room = db.Column(db.String(50))
|
||||
description = db.Column(db.Text)
|
||||
|
||||
# Classification + tree (ADR-001)
|
||||
locationtypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('locationtypes.locationtypeid'),
|
||||
nullable=True
|
||||
)
|
||||
parentlocationid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('locations.locationid'),
|
||||
nullable=True,
|
||||
comment='Parent location for the site location tree'
|
||||
)
|
||||
|
||||
# Map configuration
|
||||
mapimage = db.Column(db.String(500), comment='Path to floor map image')
|
||||
mapwidth = db.Column(db.Integer)
|
||||
mapheight = db.Column(db.Integer)
|
||||
|
||||
# Location-level position used as fallback when an asset has no own coords
|
||||
# and no inheritsposition relationship resolves. ADR-001 position resolution
|
||||
# chain priority 3.
|
||||
mapx = db.Column(db.Integer, comment='Default X coordinate for assets at this location')
|
||||
mapy = db.Column(db.Integer, comment='Default Y coordinate for assets at this location')
|
||||
|
||||
# Relationships
|
||||
locationtype = db.relationship('LocationType')
|
||||
parent = db.relationship('Location', remote_side=[locationid])
|
||||
|
||||
@property
|
||||
def locationcode(self):
|
||||
"""Derived operation/short code (leading token of the name)."""
|
||||
return derive_locationcode(self.locationname)
|
||||
|
||||
def to_dict(self):
|
||||
data = super().to_dict()
|
||||
data['locationtypename'] = self.locationtype.locationtype if self.locationtype else None
|
||||
data['parentlocationname'] = self.parent.locationname if self.parent else None
|
||||
data['locationcode'] = self.locationcode
|
||||
return data
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Location {self.locationname}>"
|
||||
43
shopdb/core/models/model.py
Normal file
43
shopdb/core/models/model.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Model (vendor catalog model number) model."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class Model(BaseModel):
|
||||
"""Vendor catalog model information (machines, PCs, printers, network)."""
|
||||
__tablename__ = 'models'
|
||||
|
||||
modelnumberid = db.Column(db.Integer, primary_key=True)
|
||||
modelnumber = db.Column(db.String(100), nullable=False)
|
||||
|
||||
# Link to model type (what kind of thing this catalog model is for)
|
||||
modeltypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('modeltypes.modeltypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Link to vendor/manufacturer
|
||||
vendorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vendors.vendorid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
description = db.Column(db.Text)
|
||||
imageurl = db.Column(db.String(500), comment='URL to product image')
|
||||
documentationurl = db.Column(db.String(500), comment='URL to documentation')
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
# Relationships
|
||||
modeltype = db.relationship('ModelType', backref='models')
|
||||
vendor = db.relationship('Vendor', backref='models')
|
||||
|
||||
# Unique constraint on modelnumber + vendor
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('modelnumber', 'vendorid', name='uq_model_vendor'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Model {self.modelnumber}>"
|
||||
33
shopdb/core/models/modeltype.py
Normal file
33
shopdb/core/models/modeltype.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Model-type lookup (types the vendor MODELS catalog).
|
||||
|
||||
The Machine instance model and its PC/status lookups were retired (ADR-001);
|
||||
assets are the platform contract. ModelType is kept because the shared `models`
|
||||
table references it via models.modeltypeid: it types vendor models (Lathe,
|
||||
Switch, Laser Printer), not asset instances. Renamed from MachineType to be
|
||||
role-accurate and to free the "machinetype" name for the machines plugin.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class ModelType(BaseModel):
|
||||
"""
|
||||
Model-type classification (what kind of thing a catalog model is for).
|
||||
Categories: Equipment, PC, Network, Printer
|
||||
"""
|
||||
__tablename__ = 'modeltypes'
|
||||
|
||||
modeltypeid = db.Column(db.Integer, primary_key=True)
|
||||
modeltype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
category = db.Column(
|
||||
db.String(50),
|
||||
nullable=False,
|
||||
default='Equipment',
|
||||
comment='Equipment, PC, Network, or Printer'
|
||||
)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ModelType {self.modeltype}>"
|
||||
22
shopdb/core/models/operatingsystem.py
Normal file
22
shopdb/core/models/operatingsystem.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Operating System model."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class OperatingSystem(BaseModel):
|
||||
"""Operating system model."""
|
||||
__tablename__ = 'operatingsystems'
|
||||
|
||||
osid = db.Column(db.Integer, primary_key=True)
|
||||
osname = db.Column(db.String(100), nullable=False)
|
||||
osversion = db.Column(db.String(50))
|
||||
architecture = db.Column(db.String(20), comment='x86, x64, ARM')
|
||||
endoflife = db.Column(db.Date, comment='End of support date')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('osname', 'osversion', name='uq_os_name_version'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OperatingSystem {self.osname} {self.osversion}>"
|
||||
166
shopdb/core/models/relationship.py
Normal file
166
shopdb/core/models/relationship.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Machine and Asset relationship models."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class RelationshipType(BaseModel):
|
||||
"""
|
||||
Types of relationships between assets.
|
||||
|
||||
ADR-001 seeds three canonical types: partof, controls, connectedto.
|
||||
Sites may add legacy/communication-flavored types (Serial Cable, Direct
|
||||
Ethernet, USB, WiFi, Dualpath) for backward compatibility with pre-1.0
|
||||
data, but new ADR-001 code paths only reason about the three canonical
|
||||
types via free-text label for nuance.
|
||||
"""
|
||||
__tablename__ = 'relationshiptypes'
|
||||
|
||||
relationshiptypeid = db.Column(db.Integer, primary_key=True)
|
||||
relationshiptype = db.Column(db.String(50), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
color = db.Column(db.String(20), comment='CSS color for relationship badges')
|
||||
|
||||
# True: edge has a source->target meaning (controls, partof, Backup For).
|
||||
# False: symmetric link (Dualpath, connectedto, USB...) shown on the card
|
||||
# once per peer with no direction, both stored direction rows collapsed.
|
||||
isdirectional = db.Column(
|
||||
db.Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
server_default='1',
|
||||
comment='False = symmetric connection, shown direction-blind'
|
||||
)
|
||||
|
||||
# Sibling propagation (ADR-001), WIRED. When a relationship of this type is
|
||||
# created, the create path fans it out across every through-type in this
|
||||
# type's propagation set (relationshiptypepropagations rows). For each
|
||||
# SYMMETRIC through-type P, the source's edge to the target is mirrored to
|
||||
# every asset linked to the target via P. This is M:N so one type can
|
||||
# propagate through several rails at once. Seeded rows on this DB:
|
||||
# controls -> partof (declared; directional rail, NOT consumed yet -
|
||||
# parent/child fan-out direction is ambiguous)
|
||||
# controls -> Dualpath (consumed; a dual-bay pair is one physical machine
|
||||
# with one controller, so both bays carry controls)
|
||||
# A dual-bay Dualpath pair thus gets the PC's controls link on both bays.
|
||||
# propagatesthrough is the list of through-type RelationshipType rows.
|
||||
propagatesthrough = db.relationship(
|
||||
'RelationshipType',
|
||||
secondary='relationshiptypepropagations',
|
||||
primaryjoin='RelationshipType.relationshiptypeid'
|
||||
' == RelationshipTypePropagation.relationshiptypeid',
|
||||
secondaryjoin='RelationshipType.relationshiptypeid'
|
||||
' == RelationshipTypePropagation.throughtypeid',
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RelationshipType {self.relationshiptype}>"
|
||||
|
||||
|
||||
class RelationshipTypePropagation(db.Model):
|
||||
"""
|
||||
M:N propagation rails (ADR-001). One row means: a relationship of type
|
||||
relationshiptypeid propagates through the connections of type throughtypeid.
|
||||
|
||||
Replaces the old single-valued relationshiptypes.propagatesthroughid so a
|
||||
type (controls) can propagate through several through-types (partof AND
|
||||
Dualpath) at once. Seed/CLI-managed; no management UI.
|
||||
"""
|
||||
__tablename__ = 'relationshiptypepropagations'
|
||||
|
||||
propagationid = db.Column(db.Integer, primary_key=True)
|
||||
relationshiptypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('relationshiptypes.relationshiptypeid'),
|
||||
nullable=False
|
||||
)
|
||||
throughtypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('relationshiptypes.relationshiptypeid'),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint(
|
||||
'relationshiptypeid',
|
||||
'throughtypeid',
|
||||
name='uq_reltype_propagation'
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RelationshipTypePropagation {self.relationshiptypeid} through {self.throughtypeid}>"
|
||||
|
||||
|
||||
class AssetRelationship(BaseModel):
|
||||
"""
|
||||
Relationships between assets.
|
||||
|
||||
Examples:
|
||||
- Computer controls Machine
|
||||
- Two machines are dualpath partners
|
||||
- Network device connects to machine
|
||||
"""
|
||||
__tablename__ = 'assetrelationships'
|
||||
|
||||
relationshipid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
sourceassetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid'),
|
||||
nullable=False
|
||||
)
|
||||
targetassetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid'),
|
||||
nullable=False
|
||||
)
|
||||
relationshiptypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('relationshiptypes.relationshiptypeid'),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Free-text description carrying domain nuance ("DNC feed",
|
||||
# "operator workstation", "ethernet PoE"). Avoids inflating type list.
|
||||
label = db.Column(db.String(200), comment='Free-text relationship description (ADR-001)')
|
||||
|
||||
# When true, resolve_asset_position walks across this edge (priority 2
|
||||
# in the resolution chain). Defaults to true for partof + controls when
|
||||
# the relationship is created via the API; nullable for legacy rows.
|
||||
inheritsposition = db.Column(
|
||||
db.Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
server_default='1',
|
||||
comment='If true, resolved-position walk follows this edge (ADR-001)'
|
||||
)
|
||||
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
sourceasset = db.relationship(
|
||||
'Asset',
|
||||
foreign_keys=[sourceassetid],
|
||||
backref='outgoing_relationships'
|
||||
)
|
||||
targetasset = db.relationship(
|
||||
'Asset',
|
||||
foreign_keys=[targetassetid],
|
||||
backref='incoming_relationships'
|
||||
)
|
||||
relationshiptype = db.relationship('RelationshipType', backref='asset_relationships')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint(
|
||||
'sourceassetid',
|
||||
'targetassetid',
|
||||
'relationshiptypeid',
|
||||
name='uq_asset_relationship'
|
||||
),
|
||||
db.Index('idx_asset_rel_source', 'sourceassetid'),
|
||||
db.Index('idx_asset_rel_target', 'targetassetid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AssetRelationship {self.sourceassetid} -> {self.targetassetid}>"
|
||||
102
shopdb/core/models/setting.py
Normal file
102
shopdb/core/models/setting.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""System settings model for key-value configuration storage."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.extensions import db
|
||||
|
||||
|
||||
def _utcnow():
|
||||
"""Naive UTC now for column defaults (matches the app's naive datetime cols)."""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class Setting(db.Model):
|
||||
"""
|
||||
Key-value store for system settings.
|
||||
|
||||
Settings can be managed via the admin UI and are cached
|
||||
for performance.
|
||||
"""
|
||||
__tablename__ = 'settings'
|
||||
|
||||
settingid = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
key = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
value = db.Column(db.Text, nullable=True)
|
||||
valuetype = db.Column(db.String(20), default='string') # string, boolean, integer, json
|
||||
category = db.Column(db.String(50), default='general') # For grouping in UI
|
||||
description = db.Column(db.String(255), nullable=True)
|
||||
createddate = db.Column(db.DateTime, default=_utcnow)
|
||||
modifieddate = db.Column(db.DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'settingid': self.settingid,
|
||||
'key': self.key,
|
||||
'value': self.get_typed_value(),
|
||||
'valuetype': self.valuetype,
|
||||
'category': self.category,
|
||||
'description': self.description,
|
||||
'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
||||
'modifieddate': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None,
|
||||
}
|
||||
|
||||
def get_typed_value(self):
|
||||
"""Return value converted to its proper type."""
|
||||
if self.value is None:
|
||||
return None
|
||||
if self.valuetype == 'boolean':
|
||||
return self.value.lower() in ('true', '1', 'yes')
|
||||
if self.valuetype == 'integer':
|
||||
try:
|
||||
return int(self.value)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
return self.value
|
||||
|
||||
@classmethod
|
||||
def get(cls, key: str, default=None):
|
||||
"""Get a setting value by key."""
|
||||
setting = cls.query.filter_by(key=key).first()
|
||||
if setting:
|
||||
return setting.get_typed_value()
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _stringify(value):
|
||||
"""Convert a value to its stored string form."""
|
||||
if isinstance(value, bool):
|
||||
return 'true' if value else 'false'
|
||||
return str(value) if value is not None else None
|
||||
|
||||
@classmethod
|
||||
def set(cls, key: str, value, valuetype: str = 'string', category: str = 'general', description: str = None):
|
||||
"""Set a setting value, creating if it doesn't exist.
|
||||
|
||||
Handles the create race: two concurrent callers can both find no row and
|
||||
both try to INSERT the same unique key. The loser's commit raises
|
||||
IntegrityError; we roll back, re-fetch the row the winner created, and
|
||||
apply our value to it.
|
||||
"""
|
||||
setting = cls.query.filter_by(key=key).first()
|
||||
if setting:
|
||||
setting.value = cls._stringify(value)
|
||||
db.session.commit()
|
||||
return setting
|
||||
|
||||
setting = cls(key=key, valuetype=valuetype, category=category,
|
||||
description=description, value=cls._stringify(value))
|
||||
db.session.add(setting)
|
||||
try:
|
||||
db.session.commit()
|
||||
return setting
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
# Another transaction inserted this key first; update that row.
|
||||
setting = cls.query.filter_by(key=key).first()
|
||||
if setting is None:
|
||||
raise
|
||||
setting.value = cls._stringify(value)
|
||||
db.session.commit()
|
||||
return setting
|
||||
61
shopdb/core/models/supportteam.py
Normal file
61
shopdb/core/models/supportteam.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Application support teams and their contacts.
|
||||
|
||||
A support team is who you contact about an application; each team carries an
|
||||
optional ServiceNow group deep link (teamurl) and a list of named contacts
|
||||
(the people you actually reach out to, legacy called them "app owners").
|
||||
Applications point at one team via applications.supportteamid.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class SupportTeam(BaseModel):
|
||||
"""A support team an application belongs to."""
|
||||
__tablename__ = 'supportteams'
|
||||
|
||||
supportteamid = db.Column(db.Integer, primary_key=True)
|
||||
teamname = db.Column(db.String(100), unique=True, nullable=False)
|
||||
teamurl = db.Column(db.Text) # ServiceNow group deep link, nullable
|
||||
|
||||
# Contacts cascade-delete with the team.
|
||||
contacts = db.relationship(
|
||||
'SupportTeamContact', back_populates='team',
|
||||
cascade='all, delete-orphan', lazy='select')
|
||||
applications = db.relationship(
|
||||
'Application', back_populates='supportteam', lazy='dynamic')
|
||||
|
||||
def active_contacts(self):
|
||||
"""Return active contacts in sortorder (then contactid) order."""
|
||||
return sorted(
|
||||
(c for c in self.contacts if c.isactive),
|
||||
key=lambda c: (c.sortorder, c.contactid or 0))
|
||||
|
||||
def to_dict(self, with_contacts=True):
|
||||
"""Serialize the team, nesting its active contacts by default."""
|
||||
result = super().to_dict()
|
||||
if with_contacts:
|
||||
result['contacts'] = [c.to_dict() for c in self.active_contacts()]
|
||||
return result
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SupportTeam {self.teamname}>"
|
||||
|
||||
|
||||
class SupportTeamContact(BaseModel):
|
||||
"""A person to contact for a support team."""
|
||||
__tablename__ = 'supportteamcontacts'
|
||||
|
||||
contactid = db.Column(db.Integer, primary_key=True)
|
||||
supportteamid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('supportteams.supportteamid', ondelete='CASCADE'),
|
||||
nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
sso = db.Column(db.String(50))
|
||||
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
team = db.relationship('SupportTeam', back_populates='contacts')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SupportTeamContact {self.name}>"
|
||||
241
shopdb/core/models/user.py
Normal file
241
shopdb/core/models/user.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""User and authentication models."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
# Association table for user roles (many-to-many)
|
||||
userroles = db.Table(
|
||||
'userroles',
|
||||
db.Column('userid', db.Integer, db.ForeignKey('users.userid'), primary_key=True),
|
||||
db.Column('roleid', db.Integer, db.ForeignKey('roles.roleid'), primary_key=True)
|
||||
)
|
||||
|
||||
# Association table for role permissions (many-to-many)
|
||||
rolepermissions = db.Table(
|
||||
'rolepermissions',
|
||||
db.Column('roleid', db.Integer, db.ForeignKey('roles.roleid'), primary_key=True),
|
||||
db.Column('permissionid', db.Integer, db.ForeignKey('permissions.permissionid'), primary_key=True)
|
||||
)
|
||||
|
||||
|
||||
class Permission(db.Model):
|
||||
"""
|
||||
Permission model for granular access control.
|
||||
|
||||
Permissions are predefined and assigned to roles.
|
||||
"""
|
||||
__tablename__ = 'permissions'
|
||||
|
||||
permissionid = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(50), unique=True, nullable=False)
|
||||
description = db.Column(db.String(255))
|
||||
category = db.Column(db.String(50), default='general') # For grouping in UI
|
||||
|
||||
# Core permission catalog. This is CORE ONLY - permissions for a plugin
|
||||
# domain (machines, computers, printers, network, knowledgebase,
|
||||
# notifications, usb, warranty, measuringtools) live in that plugin's
|
||||
# BasePlugin.get_permissions() hook, not here (plugin-is-the-product). The
|
||||
# merged core+plugins view is full_permission_catalog() below.
|
||||
CORE_PERMISSIONS = [
|
||||
# Assets
|
||||
('assets.view', 'View assets', 'assets'),
|
||||
('assets.create', 'Create assets', 'assets'),
|
||||
('assets.edit', 'Edit assets', 'assets'),
|
||||
('assets.delete', 'Delete assets', 'assets'),
|
||||
# Applications
|
||||
('applications.view', 'View applications', 'applications'),
|
||||
('applications.create', 'Create applications', 'applications'),
|
||||
('applications.edit', 'Edit applications', 'applications'),
|
||||
('applications.delete', 'Delete applications', 'applications'),
|
||||
# Reports
|
||||
('reports.view', 'View reports', 'reports'),
|
||||
('reports.export', 'Export reports', 'reports'),
|
||||
# Settings
|
||||
('settings.view', 'View settings', 'admin'),
|
||||
('settings.edit', 'Edit settings', 'admin'),
|
||||
# Users
|
||||
('users.view', 'View users', 'admin'),
|
||||
('users.create', 'Create users', 'admin'),
|
||||
('users.edit', 'Edit users', 'admin'),
|
||||
('users.delete', 'Delete users', 'admin'),
|
||||
# Audit
|
||||
('audit.view', 'View audit logs', 'admin'),
|
||||
# API tokens
|
||||
('apitokens.create', 'Create and manage API tokens', 'apitokens'),
|
||||
# Collector service tokens. A token scoped to ONLY this permission is a
|
||||
# collector service token: it authorizes the collector ingest API and
|
||||
# nothing else (scoped tokens pass require_permission only for listed
|
||||
# perms, and this perm gates no other route). See collector.py.
|
||||
('collector.ingest', 'Submit collector payloads (fleet reporting)',
|
||||
'collector'),
|
||||
]
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Permission {self.name}>"
|
||||
|
||||
@classmethod
|
||||
def seed_entries(cls, entries):
|
||||
"""Idempotently create Permission rows from (name, desc, category)
|
||||
tuples (or equivalent dicts). Returns how many were created."""
|
||||
created = 0
|
||||
for name, description, category in normalize_permission_entries(entries):
|
||||
if not cls.query.filter_by(name=name).first():
|
||||
perm = cls(name=name, description=description, category=category)
|
||||
db.session.add(perm)
|
||||
created += 1
|
||||
return created
|
||||
|
||||
@classmethod
|
||||
def seed(cls):
|
||||
"""Seed the full permission catalog (core plus enabled plugins)."""
|
||||
return cls.seed_entries(full_permission_catalog())
|
||||
|
||||
|
||||
def normalize_permission_entries(entries):
|
||||
"""Coerce catalog entries to (name, description, category) tuples.
|
||||
|
||||
Accepts tuples/lists in that order, or dicts with those keys, so a plugin
|
||||
may return either shape from get_permissions().
|
||||
"""
|
||||
normalized = []
|
||||
for entry in entries or []:
|
||||
if isinstance(entry, dict):
|
||||
normalized.append((
|
||||
entry['name'],
|
||||
entry.get('description', ''),
|
||||
entry.get('category', 'general'),
|
||||
))
|
||||
else:
|
||||
name, description, category = entry
|
||||
normalized.append((name, description, category))
|
||||
return normalized
|
||||
|
||||
|
||||
def full_permission_catalog(include_disabled=False):
|
||||
"""The full permission catalog: core permissions plus every LOADED plugin's
|
||||
get_permissions().
|
||||
|
||||
By default only ENABLED plugins contribute, so a disabled plugin's
|
||||
permissions are not offered for new scope grants or role edits (existing
|
||||
Permission rows and role links persist - see the get_permissions docstring).
|
||||
Pass include_disabled=True to include all installed plugins.
|
||||
|
||||
Follows the dashboard.py consumer pattern: a broken plugin is re-raised in
|
||||
dev/test and isolated (logged) in prod. Returns (name, description,
|
||||
category) tuples.
|
||||
"""
|
||||
from flask import current_app, has_app_context
|
||||
|
||||
catalog = list(Permission.CORE_PERMISSIONS)
|
||||
if not has_app_context():
|
||||
return catalog
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
return catalog
|
||||
|
||||
for name, plugin in pm.get_all_plugins().items():
|
||||
if not include_disabled and not pm.registry.is_enabled(name):
|
||||
continue
|
||||
try:
|
||||
catalog.extend(
|
||||
normalize_permission_entries(plugin.get_permissions()))
|
||||
except Exception:
|
||||
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
|
||||
raise
|
||||
current_app.logger.exception(
|
||||
'Plugin %s get_permissions failed', name)
|
||||
return catalog
|
||||
|
||||
|
||||
class Role(BaseModel):
|
||||
"""User role model."""
|
||||
__tablename__ = 'roles'
|
||||
|
||||
roleid = db.Column(db.Integer, primary_key=True)
|
||||
rolename = db.Column(db.String(50), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
|
||||
# Permissions relationship
|
||||
permissions = db.relationship(
|
||||
'Permission',
|
||||
secondary=rolepermissions,
|
||||
backref=db.backref('roles', lazy='dynamic')
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Role {self.rolename}>"
|
||||
|
||||
def haspermission(self, permission_name: str) -> bool:
|
||||
"""Check if role has a specific permission."""
|
||||
# Admin role has all permissions
|
||||
if self.rolename == 'admin':
|
||||
return True
|
||||
return any(p.name == permission_name for p in self.permissions)
|
||||
|
||||
def getpermissionnames(self) -> list:
|
||||
"""Get list of permission names."""
|
||||
if self.rolename == 'admin':
|
||||
return [p[0] for p in full_permission_catalog()]
|
||||
return [p.name for p in self.permissions]
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
"""User model for authentication."""
|
||||
__tablename__ = 'users'
|
||||
|
||||
userid = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
email = db.Column(db.String(255), unique=True, nullable=False)
|
||||
passwordhash = db.Column(db.String(255), nullable=False)
|
||||
|
||||
# Profile
|
||||
firstname = db.Column(db.String(100))
|
||||
lastname = db.Column(db.String(100))
|
||||
|
||||
# Status
|
||||
lastlogindate = db.Column(db.DateTime)
|
||||
failedlogins = db.Column(db.Integer, default=0)
|
||||
lockeduntil = db.Column(db.DateTime)
|
||||
# Forced password change: set true when an admin creates the account, so the
|
||||
# user is steered through a password change before landing in the app.
|
||||
mustchangepassword = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
# Relationships
|
||||
roles = db.relationship(
|
||||
'Role',
|
||||
secondary=userroles,
|
||||
backref=db.backref('users', lazy='dynamic')
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.username}>"
|
||||
|
||||
@property
|
||||
def islocked(self):
|
||||
"""Check if account is locked."""
|
||||
if self.lockeduntil:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None) < self.lockeduntil
|
||||
return False
|
||||
|
||||
def hasrole(self, rolename: str) -> bool:
|
||||
"""Check if user has a specific role."""
|
||||
return any(r.rolename == rolename for r in self.roles)
|
||||
|
||||
def haspermission(self, permission_name: str) -> bool:
|
||||
"""Check if user has a specific permission through any role."""
|
||||
# Admin role has all permissions
|
||||
if self.hasrole('admin'):
|
||||
return True
|
||||
return any(r.haspermission(permission_name) for r in self.roles)
|
||||
|
||||
def getpermissions(self) -> list:
|
||||
"""Get list of all permission names from all roles."""
|
||||
if self.hasrole('admin'):
|
||||
return [p[0] for p in full_permission_catalog()]
|
||||
|
||||
perms = set()
|
||||
for role in self.roles:
|
||||
perms.update(role.getpermissionnames())
|
||||
return list(perms)
|
||||
20
shopdb/core/models/vendor.py
Normal file
20
shopdb/core/models/vendor.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Vendor model."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class Vendor(BaseModel):
|
||||
"""Vendor/Manufacturer model."""
|
||||
__tablename__ = 'vendors'
|
||||
|
||||
vendorid = db.Column(db.Integer, primary_key=True)
|
||||
vendor = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
website = db.Column(db.String(255))
|
||||
supportphone = db.Column(db.String(50))
|
||||
supportemail = db.Column(db.String(100))
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Vendor {self.vendor}>"
|
||||
1
shopdb/core/schemas/__init__.py
Normal file
1
shopdb/core/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core Marshmallow schemas."""
|
||||
1
shopdb/core/services/__init__.py
Normal file
1
shopdb/core/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core services."""
|
||||
115
shopdb/core/services/dualpath.py
Normal file
115
shopdb/core/services/dualpath.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Dualpath single-machine collapse resolution.
|
||||
|
||||
A Dualpath relationship pair is ONE physical dual-bay machine (single
|
||||
controller, one bay-selector switch) recorded as two asset rows. Most
|
||||
facilities want lists, counts, and the map to show that pair as a single
|
||||
machine; the data model always keeps both rows. This module resolves the
|
||||
pairs so the consumers (machines list, dashboard/report counts, floor map)
|
||||
can collapse them, and is exposed via the plugin contract surface
|
||||
(shopdb.api) so the machines plugin can reach it contract-purely.
|
||||
|
||||
PRIMARY = the pair member with the lower natural-sort assetnumber; the other
|
||||
member is SECONDARY and is the one hidden when collapsing. Pairs are derived
|
||||
from active assetrelationships rows whose type is named 'Dualpath' (either
|
||||
direction; mirrored rows in both directions collapse to one pair). Only pairs
|
||||
where BOTH assets are active are resolved.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections import namedtuple
|
||||
|
||||
from shopdb.core.models import Asset, AssetRelationship, RelationshipType
|
||||
|
||||
# secondaryassetids: set of the non-primary bay asset ids (hide when collapsing)
|
||||
# partnerbyasset: {assetid -> {'assetid', 'assetnumber'}} for EVERY pair member,
|
||||
# primary and secondary alike, so detail pages can show a sibling
|
||||
# banner from whichever bay you land on.
|
||||
DualpathCollapse = namedtuple('DualpathCollapse', ['secondaryassetids', 'partnerbyasset'])
|
||||
|
||||
DUALPATH_TYPE_NAME = 'Dualpath'
|
||||
|
||||
|
||||
def _naturalkey(assetnumber):
|
||||
# split into digit/non-digit chunks so 2007 sorts before 2008 and before 10a
|
||||
parts = re.split(r'(\d+)', assetnumber or '')
|
||||
return [int(p) if p.isdigit() else p.lower() for p in parts]
|
||||
|
||||
|
||||
def resolve_dualpath_pairs():
|
||||
"""Resolve active Dualpath pairs into a DualpathCollapse.
|
||||
|
||||
Direction-blind and dedup-safe: rows stored in either direction (or both)
|
||||
for the same two assets collapse to one pair. Ignores the site toggle;
|
||||
callers gate on dualpath_single_machine_enabled() where the collapse should
|
||||
only apply when the setting is on (the detail-page banner shows always).
|
||||
"""
|
||||
reltype = RelationshipType.query.filter_by(
|
||||
relationshiptype=DUALPATH_TYPE_NAME).first()
|
||||
if not reltype:
|
||||
return DualpathCollapse(set(), {})
|
||||
|
||||
rows = AssetRelationship.query.filter(
|
||||
AssetRelationship.relationshiptypeid == reltype.relationshiptypeid,
|
||||
AssetRelationship.isactive == True,
|
||||
).all()
|
||||
if not rows:
|
||||
return DualpathCollapse(set(), {})
|
||||
|
||||
# batch-load the involved assets (assetnumber + active flag) in one query
|
||||
involved = set()
|
||||
for row in rows:
|
||||
involved.add(row.sourceassetid)
|
||||
involved.add(row.targetassetid)
|
||||
assetbyid = {
|
||||
a.assetid: a
|
||||
for a in Asset.query.filter(Asset.assetid.in_(involved)).all()
|
||||
}
|
||||
|
||||
secondaryassetids = set()
|
||||
partnerbyasset = {}
|
||||
seenpairs = set()
|
||||
for row in rows:
|
||||
aid, bid = row.sourceassetid, row.targetassetid
|
||||
if aid == bid:
|
||||
continue # defensive: no self-pairs
|
||||
pairkey = frozenset((aid, bid))
|
||||
if pairkey in seenpairs:
|
||||
continue # mirrored row already handled
|
||||
seenpairs.add(pairkey)
|
||||
|
||||
aone = assetbyid.get(aid)
|
||||
atwo = assetbyid.get(bid)
|
||||
# collapse only affects visible/counted (active) assets
|
||||
if not aone or not atwo or not aone.isactive or not atwo.isactive:
|
||||
continue
|
||||
|
||||
# PRIMARY = lower natural-sort assetnumber; SECONDARY is the other bay
|
||||
if _naturalkey(aone.assetnumber) <= _naturalkey(atwo.assetnumber):
|
||||
primary, secondary = aone, atwo
|
||||
else:
|
||||
primary, secondary = atwo, aone
|
||||
|
||||
secondaryassetids.add(secondary.assetid)
|
||||
partnerbyasset[primary.assetid] = {
|
||||
'assetid': secondary.assetid,
|
||||
'assetnumber': secondary.assetnumber,
|
||||
}
|
||||
partnerbyasset[secondary.assetid] = {
|
||||
'assetid': primary.assetid,
|
||||
'assetnumber': primary.assetnumber,
|
||||
}
|
||||
|
||||
return DualpathCollapse(secondaryassetids, partnerbyasset)
|
||||
|
||||
|
||||
def dualpath_single_machine_enabled():
|
||||
"""True when the site treats Dualpath pairs as one machine (default true).
|
||||
|
||||
Reads the cached settings; the row is absent on an un-seeded site, in which
|
||||
case the default (true - 'most places' consider a dual-bay pair one machine)
|
||||
applies.
|
||||
"""
|
||||
# imported here to avoid a settings<->api import cycle at module load
|
||||
from shopdb.core.api.settings import get_cached_settings
|
||||
settings = get_cached_settings()
|
||||
return bool(settings.get('dualpath_single_machine', True))
|
||||
70
shopdb/exceptions.py
Normal file
70
shopdb/exceptions.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Custom exceptions for ShopDB."""
|
||||
|
||||
|
||||
class ShopDBException(Exception):
|
||||
"""Base exception for ShopDB."""
|
||||
|
||||
def __init__(self, message: str, code: str = None, details: dict = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code or 'SHOPDB_ERROR'
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
class ValidationError(ShopDBException):
|
||||
"""Validation error."""
|
||||
|
||||
def __init__(self, message: str, details: dict = None):
|
||||
super().__init__(message, 'VALIDATION_ERROR', details)
|
||||
|
||||
|
||||
class NotFoundError(ShopDBException):
|
||||
"""Resource not found error."""
|
||||
|
||||
def __init__(self, resource: str, identifier):
|
||||
message = f"{resource} with ID {identifier} not found"
|
||||
super().__init__(message, 'NOT_FOUND', {'resource': resource, 'id': identifier})
|
||||
|
||||
|
||||
class AuthenticationError(ShopDBException):
|
||||
"""Authentication error."""
|
||||
|
||||
def __init__(self, message: str = "Authentication required"):
|
||||
super().__init__(message, 'UNAUTHORIZED')
|
||||
|
||||
|
||||
class AuthorizationError(ShopDBException):
|
||||
"""Authorization error."""
|
||||
|
||||
def __init__(self, message: str = "Permission denied"):
|
||||
super().__init__(message, 'FORBIDDEN')
|
||||
|
||||
|
||||
class ConflictError(ShopDBException):
|
||||
"""Conflict error (e.g., duplicate entry)."""
|
||||
|
||||
def __init__(self, message: str, details: dict = None):
|
||||
super().__init__(message, 'CONFLICT', details)
|
||||
|
||||
|
||||
class PluginError(ShopDBException):
|
||||
"""Plugin-related error."""
|
||||
|
||||
def __init__(self, message: str, plugin_name: str = None):
|
||||
super().__init__(message, 'PLUGIN_ERROR', {'plugin': plugin_name})
|
||||
|
||||
|
||||
class PluginNotFoundError(PluginError):
|
||||
"""A plugin directory or manifest is missing."""
|
||||
|
||||
|
||||
class PluginContractError(PluginError):
|
||||
"""A plugin violates the BasePlugin contract (no subclass, missing hooks)."""
|
||||
|
||||
|
||||
class PluginVersionError(PluginError):
|
||||
"""A plugin's core_version range does not include the framework's __contract_version__."""
|
||||
|
||||
|
||||
class PluginDependencyError(PluginError):
|
||||
"""A plugin declares a dependency on another plugin that is missing or not enabled."""
|
||||
28
shopdb/extensions.py
Normal file
28
shopdb/extensions.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Flask extensions initialization."""
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_jwt_extended import JWTManager
|
||||
from flask_cors import CORS
|
||||
from flask_marshmallow import Marshmallow
|
||||
from flask_caching import Cache
|
||||
|
||||
# Initialize extensions without app
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
jwt = JWTManager()
|
||||
cors = CORS()
|
||||
ma = Marshmallow()
|
||||
cache = Cache()
|
||||
|
||||
|
||||
def init_extensions(app):
|
||||
"""Initialize all Flask extensions with app."""
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
jwt.init_app(app)
|
||||
cors.init_app(app, resources={
|
||||
r"/api/*": {"origins": app.config.get('CORS_ORIGINS', '*')}
|
||||
})
|
||||
ma.init_app(app)
|
||||
cache.init_app(app)
|
||||
399
shopdb/plugins/__init__.py
Normal file
399
shopdb/plugins/__init__.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""Plugin manager - main entry point for plugin system."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from flask import Flask
|
||||
import logging
|
||||
|
||||
from .base import BasePlugin, PluginMeta
|
||||
from .registry import PluginRegistry, PluginState
|
||||
from .loader import PluginLoader
|
||||
from .migrations import PluginMigrationManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
'PluginManager',
|
||||
'BasePlugin',
|
||||
'PluginMeta',
|
||||
'PluginRegistry',
|
||||
'PluginState',
|
||||
'plugin_manager'
|
||||
]
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""
|
||||
Central manager for all plugin operations.
|
||||
|
||||
Usage:
|
||||
plugin_manager = PluginManager()
|
||||
plugin_manager.init_app(app, db)
|
||||
|
||||
# In CLI:
|
||||
plugin_manager.install_plugin('printers')
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.registry: Optional[PluginRegistry] = None
|
||||
self.loader: Optional[PluginLoader] = None
|
||||
self.migration_manager: Optional[PluginMigrationManager] = None
|
||||
self._app: Optional[Flask] = None
|
||||
self._db = None
|
||||
# API prefixes already claimed by a registered plugin blueprint, to
|
||||
# detect two plugins overlapping on the same /api/... namespace.
|
||||
self._registered_prefixes: set = set()
|
||||
|
||||
def init_app(self, app: Flask, db) -> None:
|
||||
"""Initialize plugin manager with Flask app."""
|
||||
self._app = app
|
||||
self._db = db
|
||||
# Reset per-app so the prefix-uniqueness guard tracks only this app's
|
||||
# registrations (the manager is a process-wide singleton; tests build
|
||||
# multiple apps from it).
|
||||
self._registered_prefixes = set()
|
||||
|
||||
# Setup paths
|
||||
instance_path = Path(app.instance_path)
|
||||
plugins_dir = Path(app.root_path).parent / 'plugins'
|
||||
|
||||
# Initialize components
|
||||
self.registry = PluginRegistry(instance_path / 'plugins.json')
|
||||
self.loader = PluginLoader(plugins_dir, self.registry)
|
||||
self.migration_manager = PluginMigrationManager(
|
||||
plugins_dir,
|
||||
app.config.get('SQLALCHEMY_DATABASE_URI')
|
||||
)
|
||||
|
||||
# Load enabled plugins
|
||||
self._load_enabled_plugins()
|
||||
|
||||
# Store on app for access
|
||||
app.extensions['plugin_manager'] = self
|
||||
|
||||
def _load_enabled_plugins(self) -> None:
|
||||
"""Load and register all enabled plugins."""
|
||||
plugins = self.loader.load_enabled_plugins(self._app, self._db)
|
||||
|
||||
for name, plugin in plugins.items():
|
||||
self._register_plugin_components(plugin)
|
||||
|
||||
def upgrade_all_plugins(self) -> Dict[str, str]:
|
||||
"""Run pending Alembic migrations for every discovered plugin.
|
||||
|
||||
Returns {plugin_name: 'ok'|'no-migrations'|<error str>}. Skips
|
||||
plugins with no migrations/ directory. Driven by the CLI
|
||||
(`flask plugin upgrade-all`), which every deploy runs after
|
||||
`flask db upgrade`. Each bundled plugin's chain begins with a
|
||||
stamp-only anchor (the core chain already built its tables); later
|
||||
per-plugin migrations extend that chain. See ADR-008. Idempotent.
|
||||
"""
|
||||
results: Dict[str, str] = {}
|
||||
if not self.migration_manager:
|
||||
return results
|
||||
plugin_names = list(self.registry.list_installed().keys()) \
|
||||
if hasattr(self.registry, 'list_installed') else []
|
||||
if not plugin_names:
|
||||
# Fall back to whatever the loader found on disk.
|
||||
plugin_names = self.loader.discover_plugins()
|
||||
for name in plugin_names:
|
||||
if not self.migration_manager.has_pending_migrations(name):
|
||||
results[name] = 'no-migrations'
|
||||
continue
|
||||
try:
|
||||
ok = self.migration_manager.run_plugin_migrations(name)
|
||||
results[name] = 'ok' if ok else 'failed'
|
||||
except Exception as ex:
|
||||
results[name] = f'error: {ex}'
|
||||
return results
|
||||
|
||||
def _register_plugin_components(self, plugin: BasePlugin) -> None:
|
||||
"""Register plugin's blueprint, models, CLI commands, etc."""
|
||||
# Register blueprint
|
||||
blueprint = plugin.get_blueprint()
|
||||
if blueprint:
|
||||
prefix = plugin.meta.api_prefix
|
||||
# Guard against two plugins claiming the same API prefix; Flask only
|
||||
# rejects duplicate blueprint names, not overlapping url_prefixes, so
|
||||
# an overlap would silently shadow routes.
|
||||
if prefix in self._registered_prefixes:
|
||||
raise ValueError(
|
||||
f"Plugin {plugin.meta.name} api_prefix '{prefix}' is already "
|
||||
f"claimed by another blueprint"
|
||||
)
|
||||
self._app.register_blueprint(blueprint, url_prefix=prefix)
|
||||
self._registered_prefixes.add(prefix)
|
||||
logger.debug(f"Registered blueprint: {prefix}")
|
||||
|
||||
# Register CLI commands
|
||||
for cmd in plugin.get_cli_commands():
|
||||
self._app.cli.add_command(cmd)
|
||||
|
||||
def discover_available(self) -> List[Dict]:
|
||||
"""
|
||||
Get list of all available plugins (installed or not).
|
||||
Returns list of plugin info dicts.
|
||||
"""
|
||||
available = []
|
||||
|
||||
for name in self.loader.discover_plugins():
|
||||
plugin_class = self.loader.load_plugin_class(name)
|
||||
if plugin_class:
|
||||
try:
|
||||
temp = plugin_class()
|
||||
meta = temp.meta
|
||||
state = self.registry.get(name)
|
||||
|
||||
try:
|
||||
config_schema = temp.get_config_schema()
|
||||
except Exception:
|
||||
config_schema = []
|
||||
try:
|
||||
provisioning_note = temp.get_provisioning_note()
|
||||
except Exception:
|
||||
provisioning_note = None
|
||||
manifest = self.loader.load_manifest(name)
|
||||
available.append({
|
||||
'name': meta.name,
|
||||
# Human label for UIs. Manifest display_name wins; else
|
||||
# title-case the machine name (right for most, e.g.
|
||||
# "computers" -> "Computers").
|
||||
'displayname': (manifest.get('display_name')
|
||||
or meta.name.replace('_', ' ').title()),
|
||||
'version': meta.version,
|
||||
'description': meta.description,
|
||||
'author': meta.author,
|
||||
'dependencies': meta.dependencies,
|
||||
'installed': state is not None,
|
||||
'enabled': state.enabled if state else False,
|
||||
'installedat': state.installed_at if state else None,
|
||||
'config_schema': config_schema,
|
||||
'provisioning_note': provisioning_note,
|
||||
'default_enabled': manifest.get('default_enabled', True),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Error inspecting plugin {name}: {e}")
|
||||
|
||||
return available
|
||||
|
||||
def install_plugin(self, name: str, run_migrations: bool = True) -> bool:
|
||||
"""
|
||||
Install a plugin.
|
||||
|
||||
Steps:
|
||||
1. Verify plugin exists
|
||||
2. Check dependencies
|
||||
3. Run database migrations
|
||||
4. Register in registry
|
||||
5. Call plugin's on_install hook
|
||||
"""
|
||||
# Check if already installed
|
||||
if self.registry.is_installed(name):
|
||||
logger.warning(f"Plugin {name} is already installed")
|
||||
return False
|
||||
|
||||
# Read metadata from the manifest (single source of truth) instead of
|
||||
# instantiating the plugin class just to inspect deps/version.
|
||||
manifest = self.loader.load_manifest(name)
|
||||
if not manifest:
|
||||
logger.error(f"Plugin {name} not found")
|
||||
return False
|
||||
manifest_version = manifest.get('version')
|
||||
|
||||
# Check dependencies
|
||||
for dep in manifest.get('dependencies', []):
|
||||
if not self.registry.is_installed(dep):
|
||||
logger.error(
|
||||
f"Plugin {name} requires {dep} to be installed first"
|
||||
)
|
||||
return False
|
||||
|
||||
# Run migrations
|
||||
if run_migrations:
|
||||
success = self.migration_manager.run_plugin_migrations(name)
|
||||
if not success:
|
||||
logger.error(f"Failed to run migrations for {name}")
|
||||
return False
|
||||
|
||||
# Register plugin
|
||||
# Plugins that provision extra tables install disabled until a site
|
||||
# opts in (manifest default_enabled=false).
|
||||
self.registry.register(name, manifest_version,
|
||||
enabled=manifest.get('default_enabled', True))
|
||||
|
||||
# Load the plugin
|
||||
plugin = self.loader.load_plugin(name, self._app, self._db)
|
||||
if plugin:
|
||||
self._register_plugin_components(plugin)
|
||||
self._seed_plugin_permissions(plugin)
|
||||
plugin.on_install(self._app)
|
||||
|
||||
logger.info(f"Installed plugin: {name} v{manifest_version}")
|
||||
return True
|
||||
|
||||
def _seed_plugin_permissions(self, plugin: BasePlugin) -> None:
|
||||
"""Idempotently create Permission rows for a plugin's declared perms.
|
||||
|
||||
Runs at install and enable so a fresh plugin's RBAC permissions exist
|
||||
without a separate `flask seed permissions` pass. Best-effort: a plugin
|
||||
that raises must not abort the lifecycle."""
|
||||
try:
|
||||
entries = plugin.get_permissions() or []
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"get_permissions failed for %s", plugin.meta.name)
|
||||
return
|
||||
if not entries:
|
||||
return
|
||||
from shopdb.core.models import Permission
|
||||
with self._app.app_context():
|
||||
created = Permission.seed_entries(entries)
|
||||
if created:
|
||||
self._db.session.commit()
|
||||
logger.info(
|
||||
"Seeded %d permission(s) for plugin %s",
|
||||
created, plugin.meta.name)
|
||||
|
||||
def uninstall_plugin(self, name: str, remove_data: bool = False) -> bool:
|
||||
"""
|
||||
Uninstall a plugin.
|
||||
|
||||
Args:
|
||||
name: Plugin name
|
||||
remove_data: If True, run downgrade migrations to remove tables
|
||||
"""
|
||||
if not self.registry.is_installed(name):
|
||||
logger.warning(f"Plugin {name} is not installed")
|
||||
return False
|
||||
|
||||
# Check if other plugins depend on this one
|
||||
for other_name in self.registry.get_enabled_plugins():
|
||||
if other_name == name:
|
||||
continue
|
||||
other_plugin = self.loader.get_loaded_plugin(other_name)
|
||||
if other_plugin and name in other_plugin.meta.dependencies:
|
||||
logger.error(
|
||||
f"Cannot uninstall {name}: {other_name} depends on it"
|
||||
)
|
||||
return False
|
||||
|
||||
# Get plugin instance
|
||||
plugin = self.loader.get_loaded_plugin(name)
|
||||
|
||||
# Call on_uninstall hook
|
||||
if plugin:
|
||||
plugin.on_uninstall(self._app)
|
||||
|
||||
# Optionally remove data
|
||||
if remove_data:
|
||||
self.migration_manager.downgrade_plugin(name)
|
||||
|
||||
# Unregister
|
||||
self.registry.unregister(name)
|
||||
|
||||
logger.info(f"Uninstalled plugin: {name}")
|
||||
return True
|
||||
|
||||
def enable_plugin(self, name: str) -> bool:
|
||||
"""Enable a disabled plugin."""
|
||||
if not self.registry.is_installed(name):
|
||||
logger.error(f"Plugin {name} is not installed")
|
||||
return False
|
||||
|
||||
if self.registry.is_enabled(name):
|
||||
logger.info(f"Plugin {name} is already enabled")
|
||||
return True
|
||||
|
||||
# Check dependencies are enabled. Read deps from the manifest, not by
|
||||
# instantiating the plugin class (manifest is the single source of
|
||||
# truth; instantiating fires __init__ side effects unnecessarily).
|
||||
manifest = self.loader.load_manifest(name)
|
||||
for dep in manifest.get('dependencies', []):
|
||||
if not self.registry.is_enabled(dep):
|
||||
logger.error(f"Cannot enable {name}: {dep} is not enabled")
|
||||
return False
|
||||
|
||||
self.registry.enable(name)
|
||||
|
||||
# Surface (do not auto-apply) a plugin chain that is ahead of the DB, so
|
||||
# an operator enabling a plugin knows to run `flask plugin upgrade-all`.
|
||||
try:
|
||||
if self.migration_manager and \
|
||||
self.migration_manager.has_unapplied_migrations(name):
|
||||
logger.warning(
|
||||
f"Plugin {name} has unapplied migrations; "
|
||||
f"run 'flask plugin upgrade-all'"
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(f"Could not check migration state for {name}")
|
||||
|
||||
# Fire the on_enable hook best-effort. Do NOT register the blueprint
|
||||
# here: Flask forbids register_blueprint after the first request, so
|
||||
# routes/nav for a re-enabled plugin take effect on the next restart
|
||||
# (symmetric with disable).
|
||||
try:
|
||||
plugin = self.loader.load_plugin(name, self._app, self._db)
|
||||
if plugin:
|
||||
self._seed_plugin_permissions(plugin)
|
||||
plugin.on_enable(self._app)
|
||||
except Exception:
|
||||
logger.exception(f"on_enable hook failed for plugin {name}")
|
||||
|
||||
logger.info(f"Enabled plugin: {name}")
|
||||
return True
|
||||
|
||||
def disable_plugin(self, name: str) -> bool:
|
||||
"""Disable an enabled plugin."""
|
||||
if not self.registry.is_enabled(name):
|
||||
logger.info(f"Plugin {name} is already disabled")
|
||||
return True
|
||||
|
||||
# Check if other plugins depend on this one
|
||||
for other_name in self.registry.get_enabled_plugins():
|
||||
if other_name == name:
|
||||
continue
|
||||
other_plugin = self.loader.get_loaded_plugin(other_name)
|
||||
if other_plugin and name in other_plugin.meta.dependencies:
|
||||
logger.error(
|
||||
f"Cannot disable {name}: {other_name} depends on it"
|
||||
)
|
||||
return False
|
||||
|
||||
plugin = self.loader.get_loaded_plugin(name)
|
||||
if plugin:
|
||||
plugin.on_disable(self._app)
|
||||
|
||||
self.registry.disable(name)
|
||||
logger.info(f"Disabled plugin: {name}")
|
||||
return True
|
||||
|
||||
def get_plugin(self, name: str) -> Optional[BasePlugin]:
|
||||
"""Get a loaded plugin instance."""
|
||||
return self.loader.get_loaded_plugin(name)
|
||||
|
||||
def get_all_plugins(self) -> Dict[str, BasePlugin]:
|
||||
"""Get all loaded plugins."""
|
||||
return self.loader.get_all_loaded()
|
||||
|
||||
def get_service(self, name: str):
|
||||
"""Resolve a service exposed by an enabled plugin via get_services().
|
||||
|
||||
Consumer for the BasePlugin.get_services hook: searches enabled plugins
|
||||
for one that registers `name` and returns the registered value (a service
|
||||
class or factory). Returns None if no enabled plugin provides it. This is
|
||||
how one plugin obtains another's service (e.g. the Zabbix service).
|
||||
"""
|
||||
for plugin_name, plugin in self.get_all_plugins().items():
|
||||
if not self.registry.is_enabled(plugin_name):
|
||||
continue
|
||||
try:
|
||||
services = plugin.get_services() or {}
|
||||
except Exception:
|
||||
continue
|
||||
if name in services:
|
||||
return services[name]
|
||||
return None
|
||||
|
||||
|
||||
# Global plugin manager instance
|
||||
plugin_manager = PluginManager()
|
||||
166
shopdb/plugins/alembic_template.py
Normal file
166
shopdb/plugins/alembic_template.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Shared Alembic env.py logic for bundled plugins.
|
||||
|
||||
Every bundled plugin that owns tables (computers, employees, knowledgebase,
|
||||
machines, network, notifications, printers, slides, usb, warranty) has a
|
||||
`migrations/env.py` that does the minimum:
|
||||
|
||||
import os
|
||||
os.environ['PLUGIN_NAME'] = 'computers'
|
||||
from shopdb.plugins.alembic_template import run_migrations
|
||||
run_migrations()
|
||||
|
||||
This module wires the plugin's models into a MetaData object filtered to
|
||||
only the tables that belong to that plugin, then runs Alembic in either
|
||||
offline or online mode against the Flask app's configured engine.
|
||||
|
||||
Ownership cutover (see ADR-008): the core Alembic chain created every table
|
||||
that exists through its head (`7d16_directoryemployees`), including the plugin
|
||||
tables. Each plugin's `0001` migration is therefore a stamp-only no-op that
|
||||
just records the anchor revision in `alembic_version_<plugin>`. NEW plugin
|
||||
schema changes land as `plugins/<name>/migrations/000N` from here on, never in
|
||||
the core chain.
|
||||
|
||||
Plugin tables must be importable via `plugins.<name>.models`. Plugins
|
||||
register their `__tablename__` set in PLUGIN_TABLE_OWNERS below so the
|
||||
filter is explicit (avoids depending on import-side-effect global state).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
from typing import Iterable
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import MetaData, pool
|
||||
|
||||
logger = logging.getLogger('alembic.env.plugin')
|
||||
|
||||
# Explicit table-ownership map. Adding tables to a plugin requires updating
|
||||
# this dict so the per-plugin migration knows which tables to include.
|
||||
PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'computers': ('computertypes', 'computers', 'computerinstalledapps',
|
||||
'accessprotocols', 'computeraccess'),
|
||||
'employees': ('directoryemployees',),
|
||||
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
|
||||
'manifestentryhostnames', 'manifestentrymachinenumbers',
|
||||
'manifestinusechecks', 'manifestinusecheckprocesses',
|
||||
'manifestpublishedversions', 'manifestpayloads',
|
||||
'manifestenforcementreports', 'manifestenforcementresults',
|
||||
'pctypealiases'),
|
||||
'knowledgebase': ('knowledgebase',),
|
||||
'machines': ('machinetypes', 'machines'),
|
||||
'measuringtools': ('measuringtooltypes', 'measuringtools'),
|
||||
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
|
||||
'notifications': ('notificationtypes', 'notifications'),
|
||||
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'),
|
||||
'slides': ('tvslides',),
|
||||
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
||||
'warranty': ('warranties', 'warrantyassets'),
|
||||
}
|
||||
|
||||
|
||||
def _get_plugin_metadata(plugin_name: str) -> MetaData:
|
||||
"""Import the plugin's models and return a MetaData containing only its
|
||||
declared tables (filtered via PLUGIN_TABLE_OWNERS)."""
|
||||
owned = set(PLUGIN_TABLE_OWNERS.get(plugin_name, ()))
|
||||
if not owned:
|
||||
raise RuntimeError(
|
||||
f"PLUGIN_TABLE_OWNERS has no entry for plugin '{plugin_name}'. "
|
||||
f"Update shopdb/plugins/alembic_template.py."
|
||||
)
|
||||
|
||||
# Importing models attaches them to the global db.metadata.
|
||||
importlib.import_module(f'plugins.{plugin_name}.models')
|
||||
from shopdb.extensions import db
|
||||
full = db.metadata
|
||||
|
||||
plugin_md = MetaData()
|
||||
for table in list(full.tables.values()):
|
||||
if table.name in owned:
|
||||
table.to_metadata(plugin_md)
|
||||
return plugin_md
|
||||
|
||||
|
||||
def create_plugin_tables(plugin_name: str):
|
||||
"""Emit CreateTable DDL for every table this plugin owns. Idempotent
|
||||
via Alembic's batch_op.create_table behavior (raises if exists; the
|
||||
baseline migration is meant to run against an empty schema).
|
||||
|
||||
Called from each plugin's 0001_baseline.py upgrade() so the table
|
||||
definitions stay sourced from the SQLAlchemy models rather than being
|
||||
duplicated in handwritten Alembic ops.
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy.schema import CreateTable
|
||||
|
||||
md = _get_plugin_metadata(plugin_name)
|
||||
bind = op.get_bind()
|
||||
# Sort by FK dependency so parent tables are created first.
|
||||
for table in md.sorted_tables:
|
||||
op.execute(str(CreateTable(table).compile(dialect=bind.dialect)))
|
||||
|
||||
|
||||
def drop_plugin_tables(plugin_name: str):
|
||||
"""Mirror of create_plugin_tables for downgrade(). Drops in reverse FK
|
||||
order."""
|
||||
from alembic import op
|
||||
|
||||
md = _get_plugin_metadata(plugin_name)
|
||||
for table in reversed(md.sorted_tables):
|
||||
op.execute(f'DROP TABLE IF EXISTS "{table.name}"')
|
||||
|
||||
|
||||
def run_migrations():
|
||||
"""Entry point called by each plugin's migrations/env.py."""
|
||||
plugin_name = os.environ.get('PLUGIN_NAME')
|
||||
if not plugin_name:
|
||||
raise RuntimeError("PLUGIN_NAME env var must be set before run_migrations()")
|
||||
|
||||
config = context.config
|
||||
target_metadata = _get_plugin_metadata(plugin_name)
|
||||
|
||||
# Per-plugin version table so each plugin's chain is independent of core
|
||||
# Alembic's alembic_version table.
|
||||
version_table = f'alembic_version_{plugin_name}'
|
||||
|
||||
db_url = config.get_main_option('sqlalchemy.url')
|
||||
if not db_url:
|
||||
# Pull from the Flask app config if running inside an app context
|
||||
# (e.g. via flask plugin migrate <name>).
|
||||
try:
|
||||
from flask import current_app
|
||||
db_url = current_app.config['SQLALCHEMY_DATABASE_URI']
|
||||
config.set_main_option('sqlalchemy.url', db_url.replace('%', '%%'))
|
||||
except Exception as ex:
|
||||
raise RuntimeError(
|
||||
"sqlalchemy.url not set and no Flask app context available. "
|
||||
f"Original error: {ex}"
|
||||
)
|
||||
|
||||
if context.is_offline_mode():
|
||||
context.configure(
|
||||
url=db_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
version_table=version_table,
|
||||
include_schemas=False,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
else:
|
||||
# Build the engine straight from the resolved URL. The plugin manager
|
||||
# drives this via a programmatic alembic Config (no ini file), so
|
||||
# config.get_section returns an empty dict and engine_from_config would
|
||||
# find no sqlalchemy.url. db_url is already resolved above.
|
||||
from sqlalchemy import create_engine
|
||||
connectable = create_engine(db_url, poolclass=pool.NullPool)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
version_table=version_table,
|
||||
include_schemas=False,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
350
shopdb/plugins/base.py
Normal file
350
shopdb/plugins/base.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""Base plugin class that all plugins must inherit from."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Optional, Type
|
||||
from dataclasses import dataclass, field
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginMeta:
|
||||
"""Plugin metadata container."""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
description: str
|
||||
author: str = ""
|
||||
dependencies: List[str] = field(default_factory=list)
|
||||
# Default to the current pre-1.0 contract range; tighten when 1.0 lands
|
||||
core_version: str = ">=0.2.0,<1.0.0"
|
||||
api_prefix: str = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.api_prefix is None:
|
||||
self.api_prefix = f"/api/{self.name.replace('_', '-')}"
|
||||
|
||||
|
||||
class BasePlugin(ABC):
|
||||
"""
|
||||
Base class for all ShopDB plugins.
|
||||
|
||||
Plugins must implement:
|
||||
- meta: PluginMeta instance
|
||||
- get_blueprint(): Return Flask Blueprint for API routes
|
||||
- get_models(): Return list of SQLAlchemy model classes
|
||||
|
||||
Optionally implement:
|
||||
- init_app(app, db): Custom initialization
|
||||
- get_cli_commands(): Return Click commands
|
||||
- get_services(): Return service classes
|
||||
- on_install(): Called when plugin is installed
|
||||
- on_uninstall(): Called when plugin is uninstalled
|
||||
- on_enable(): Called when plugin is enabled
|
||||
- on_disable(): Called when plugin is disabled
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
pass
|
||||
|
||||
def init_app(self, app: Flask, db) -> None:
|
||||
"""
|
||||
Initialize plugin with Flask app.
|
||||
Override for custom initialization.
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return list of Click command groups/commands."""
|
||||
return []
|
||||
|
||||
def get_services(self) -> Dict[str, Type]:
|
||||
"""Return dict of service name -> service class."""
|
||||
return {}
|
||||
|
||||
def get_provisioning_note(self) -> Optional[Dict]:
|
||||
"""Transparency note shown when a site enables this plugin.
|
||||
|
||||
Return None for plugins that need no special setup. For plugins that
|
||||
create extra tables (e.g. a self-hosted directory or USB tables), return:
|
||||
{
|
||||
'tables': ['directoryemployees', ...], # created in the shopdb DB
|
||||
'note': 'Plain-language what/why.',
|
||||
'docs': 'plugins/<name>/README.md', # where the schema lives
|
||||
}
|
||||
The setup wizard shows this the moment the plugin is checked.
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_config_schema(self) -> List[Dict]:
|
||||
"""Declare the config fields this plugin needs, for the setup wizard.
|
||||
|
||||
Each field is a dict:
|
||||
key - the Setting key (non-secret) it maps to
|
||||
label - human label
|
||||
type - 'text' | 'number' | 'password'
|
||||
secret - True for credentials; these are NOT stored in the DB, the
|
||||
wizard emits an .env line for the operator to paste instead
|
||||
envvar - (secret only) the .env variable name to emit
|
||||
default - optional default shown as a placeholder
|
||||
help - optional hint
|
||||
Return [] (default) if the plugin needs no configuration.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_setting(self, key: str, default=None):
|
||||
"""Read a plugin-scoped setting from the core Setting store.
|
||||
|
||||
Settings are namespaced by plugin name to avoid collisions
|
||||
across plugins. The on-disk key is `plugin.<pluginname>.<key>`.
|
||||
|
||||
Returns the typed value (string, integer, boolean, etc.) or
|
||||
the default if not set.
|
||||
"""
|
||||
from shopdb.core.models import Setting
|
||||
namespaced_key = f'plugin.{self.meta.name}.{key}'
|
||||
return Setting.get(namespaced_key, default)
|
||||
|
||||
def set_setting(self, key: str, value, valuetype: str = 'string',
|
||||
description: str = None) -> None:
|
||||
"""Write a plugin-scoped setting to the core Setting store.
|
||||
|
||||
Settings are namespaced by plugin name. Persists immediately and
|
||||
survives restarts.
|
||||
"""
|
||||
from shopdb.core.models import Setting
|
||||
namespaced_key = f'plugin.{self.meta.name}.{key}'
|
||||
Setting.set(
|
||||
namespaced_key,
|
||||
value,
|
||||
valuetype=valuetype,
|
||||
category=f'plugin.{self.meta.name}',
|
||||
description=description,
|
||||
)
|
||||
|
||||
def get_collector_schema(self) -> Optional[Dict]:
|
||||
"""Return JSON Schema describing the collector payload for this plugin.
|
||||
|
||||
Return None if the plugin does not accept collector input.
|
||||
|
||||
See ADR-006 for the contract. The schema must include:
|
||||
- 'identityfield': name of the field that uniquely identifies an
|
||||
asset across submissions (e.g., 'hostname' for PCs,
|
||||
'macaddress' for network devices). Used for idempotent upsert.
|
||||
- 'fields': JSON Schema definitions for the rest of the payload.
|
||||
|
||||
Plugins returning a non-None schema have an endpoint at
|
||||
/api/collector/<pluginname> auto-registered by the loader.
|
||||
"""
|
||||
return None
|
||||
|
||||
def apply_collector_payload(self, payload: Dict) -> Dict:
|
||||
"""Idempotently upsert an asset from a validated collector payload.
|
||||
|
||||
Called by the generic /api/collector/<pluginname> endpoint after the
|
||||
payload passed schema validation. Plugins that return a schema from
|
||||
get_collector_schema must implement this. Return a dict with at least:
|
||||
- 'action': 'created' | 'updated' | 'noop'
|
||||
- 'assetid': the affected asset id (or None)
|
||||
- 'warnings': list[str]
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{self.meta.name} declares a collector schema but does not "
|
||||
f"implement apply_collector_payload"
|
||||
)
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed via CLI."""
|
||||
pass
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled via CLI."""
|
||||
pass
|
||||
|
||||
def on_enable(self, app: Flask) -> None:
|
||||
"""Called when plugin is enabled."""
|
||||
pass
|
||||
|
||||
def on_disable(self, app: Flask) -> None:
|
||||
"""Called when plugin is disabled."""
|
||||
pass
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""
|
||||
Return dashboard widget definitions.
|
||||
|
||||
Each widget: {
|
||||
'name': str,
|
||||
'component': str, # Frontend component name
|
||||
'endpoint': str, # API endpoint for data
|
||||
'size': str, # 'small', 'medium', 'large'
|
||||
'position': int # Order on dashboard
|
||||
}
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""
|
||||
Return navigation menu items.
|
||||
|
||||
Each item: {
|
||||
'name': str,
|
||||
'icon': str,
|
||||
'route': str,
|
||||
'position': int,
|
||||
'children': []
|
||||
}
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""
|
||||
Return the RBAC permissions this plugin owns.
|
||||
|
||||
Each entry is a (name, description, category) tuple, matching the core
|
||||
permission catalog shape (dicts with those keys are also accepted):
|
||||
|
||||
[
|
||||
('machines.view', 'View machines', 'machines'),
|
||||
('machines.create', 'Create machines', 'machines'),
|
||||
('machines.edit', 'Edit machines', 'machines'),
|
||||
('machines.delete', 'Delete machines', 'machines'),
|
||||
]
|
||||
|
||||
A plugin owns the permission names its own routes enforce via
|
||||
require_permission; core no longer accumulates them. The names must
|
||||
follow the naming convention (lowercase dotted, e.g. `machines.edit`).
|
||||
|
||||
Consumed by full_permission_catalog(): core permissions plus every
|
||||
ENABLED plugin's get_permissions(). That catalog backs `flask seed
|
||||
permissions`, the role-management grid (GET /api/users/permissions),
|
||||
and API-token scope validation. Plugin install/enable also seeds the
|
||||
plugin's own permissions idempotently.
|
||||
|
||||
Disabled plugins are skipped by the catalog, so their permissions are
|
||||
no longer offered for new scope grants or new role assignments. The
|
||||
Permission ROWS already in the database are NOT deleted, so roles that
|
||||
already reference them keep working until an admin edits the role. A
|
||||
broken plugin is isolated in prod and re-raised in dev/test.
|
||||
|
||||
Return [] (the default) if the plugin needs no permissions.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_reports(self) -> List[Dict]:
|
||||
"""
|
||||
Return report card definitions for the Reports hub.
|
||||
|
||||
Each entry: {
|
||||
'id': str, # stable report id
|
||||
'name': str, # card title
|
||||
'description': str, # one-line blurb
|
||||
'category': str, # grouping key (lowercase)
|
||||
# plus EXACTLY ONE of:
|
||||
'route': str, # frontend path for a dedicated report page
|
||||
'endpoint': str, # API endpoint for generic inline rendering
|
||||
}
|
||||
|
||||
Consumed by GET /api/reports, which merges these after the static core
|
||||
reports. Disabled plugins are skipped by the consumer.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_settings_cards(self) -> List[Dict]:
|
||||
"""
|
||||
Return settings-catalog card definitions (ADR-010).
|
||||
|
||||
Each card contributes an entry to the settings rail + landing overview
|
||||
without a plugin hand-editing the core settingsNav.js catalog.
|
||||
|
||||
Each card: {
|
||||
'group': str, # rail group title (created if new)
|
||||
'to': str, # settings route the card links to
|
||||
'icon': str, # string key, mapped to a Lucide icon core-side
|
||||
'title': str, # card title
|
||||
'description': str, # one-line blurb
|
||||
'position': int, # order within the group
|
||||
}
|
||||
|
||||
Consumed by GET /api/pluginui/settings-cards, which merges enabled
|
||||
plugins' cards into the core catalog. Disabled plugins are skipped;
|
||||
a broken plugin is isolated in prod, re-raised in dev/test.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_asset_panels(self) -> List[Dict]:
|
||||
"""
|
||||
Return asset-detail extension-panel definitions (ADR-010).
|
||||
|
||||
A generic core AssetPanel component renders each panel on the asset
|
||||
detail pages whose type matches, fetching the panel's endpoint. This
|
||||
replaces hand-composing a plugin panel component into each detail view.
|
||||
|
||||
Each panel: {
|
||||
'id': str, # stable panel id
|
||||
'title': str, # panel heading
|
||||
'assettypes': List[str], # AssetType keys it appears on; ['*'] = all
|
||||
'endpoint': str, # data endpoint (may contain {assetid})
|
||||
'render': str, # 'keyvalue' | 'table' | 'badge'
|
||||
'position': int, # order among panels
|
||||
}
|
||||
|
||||
Consumed by GET /api/pluginui/asset-panels?assetid=<id>, which returns
|
||||
the panels matching that asset's type. Disabled plugins
|
||||
are skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
||||
A panel needing bespoke UI is out of scope for the data-only hook.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_map_overlays(self) -> List[Dict]:
|
||||
"""
|
||||
Return shop-floor map overlay/decoration definitions (ADR-010).
|
||||
|
||||
The map is data-driven off asset types + positions; an overlay adds
|
||||
decoration data (a badge or ring on already-placed markers) plus a
|
||||
legend entry, without the plugin shipping any map code.
|
||||
|
||||
Each overlay: {
|
||||
'id': str, # stable overlay id
|
||||
'label': str, # legend label
|
||||
'endpoint': str, # returns [{assetid, color, label}] to decorate
|
||||
'style': str, # 'badge' | 'ring'
|
||||
'legend': bool, # True to add a legend entry
|
||||
}
|
||||
|
||||
Consumed by GET /api/pluginui/map-overlays. Disabled plugins are
|
||||
skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_asset_presentation(self) -> List[Dict]:
|
||||
"""
|
||||
Return asset-type presentation/routing definitions (ADR-010).
|
||||
|
||||
Declares how a plugin-owned asset type renders in global-search rows
|
||||
and cross-links: which icon to show and where the detail link points,
|
||||
so core never hardcodes a plugin's route or icon.
|
||||
|
||||
Each entry: {
|
||||
'assettype': str, # AssetType.assettype key the plugin owns
|
||||
'icon': str, # string key, mapped to a Lucide icon core-side
|
||||
'label': str, # human label for the type
|
||||
'route': str, # detail-route pattern (may contain {assetid})
|
||||
}
|
||||
|
||||
Consumed by GET /api/pluginui/asset-presentation. Disabled plugins are
|
||||
skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
||||
"""
|
||||
return []
|
||||
281
shopdb/plugins/cli.py
Normal file
281
shopdb/plugins/cli.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""Flask CLI commands for plugin management."""
|
||||
|
||||
import click
|
||||
from flask import current_app
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
|
||||
@click.group('plugin')
|
||||
def plugin_cli():
|
||||
"""Plugin management commands."""
|
||||
pass
|
||||
|
||||
|
||||
@plugin_cli.command('list')
|
||||
@with_appcontext
|
||||
def list_plugins():
|
||||
"""List all available plugins."""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
return
|
||||
|
||||
plugins = pm.discover_available()
|
||||
|
||||
if not plugins:
|
||||
click.echo("No plugins found in plugins directory.")
|
||||
return
|
||||
|
||||
# Format output
|
||||
click.echo("")
|
||||
click.echo(click.style("Available Plugins:", fg='cyan', bold=True))
|
||||
click.echo("-" * 60)
|
||||
|
||||
for p in plugins:
|
||||
if p['enabled']:
|
||||
status = click.style("[Enabled]", fg='green')
|
||||
elif p['installed']:
|
||||
status = click.style("[Disabled]", fg='yellow')
|
||||
else:
|
||||
status = click.style("[Available]", fg='white')
|
||||
|
||||
click.echo(f" {p['name']:20} v{p['version']:10} {status}")
|
||||
if p['description']:
|
||||
click.echo(f" {p['description'][:55]}...")
|
||||
if p['dependencies']:
|
||||
deps = ', '.join(p['dependencies'])
|
||||
click.echo(f" Dependencies: {deps}")
|
||||
|
||||
click.echo("")
|
||||
|
||||
|
||||
@plugin_cli.command('install')
|
||||
@click.argument('name')
|
||||
@click.option('--skip-migrations', is_flag=True, help='Skip database migrations')
|
||||
@with_appcontext
|
||||
def install_plugin(name: str, skip_migrations: bool):
|
||||
"""
|
||||
Install a plugin.
|
||||
|
||||
Usage: flask plugin install printers
|
||||
"""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
click.echo(f"Installing plugin: {name}")
|
||||
|
||||
if pm.install_plugin(name, run_migrations=not skip_migrations):
|
||||
click.echo(click.style(f"Successfully installed {name}", fg='green'))
|
||||
else:
|
||||
click.echo(click.style(f"Failed to install {name}", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@plugin_cli.command('uninstall')
|
||||
@click.argument('name')
|
||||
@click.option('--remove-data', is_flag=True, help='Remove plugin database tables')
|
||||
@click.confirmation_option(prompt='Are you sure you want to uninstall this plugin?')
|
||||
@with_appcontext
|
||||
def uninstall_plugin(name: str, remove_data: bool):
|
||||
"""
|
||||
Uninstall a plugin.
|
||||
|
||||
Usage: flask plugin uninstall printers
|
||||
"""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
click.echo(f"Uninstalling plugin: {name}")
|
||||
|
||||
if pm.uninstall_plugin(name, remove_data=remove_data):
|
||||
click.echo(click.style(f"Successfully uninstalled {name}", fg='green'))
|
||||
else:
|
||||
click.echo(click.style(f"Failed to uninstall {name}", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@plugin_cli.command('enable')
|
||||
@click.argument('name')
|
||||
@with_appcontext
|
||||
def enable_plugin(name: str):
|
||||
"""Enable a disabled plugin."""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
if pm.enable_plugin(name):
|
||||
click.echo(click.style(f"Enabled {name}", fg='green'))
|
||||
# Nudge the operator if the plugin's chain is ahead of the DB.
|
||||
try:
|
||||
if pm.migration_manager and \
|
||||
pm.migration_manager.has_unapplied_migrations(name):
|
||||
click.echo(click.style(
|
||||
f" {name} has unapplied migrations - "
|
||||
f"run 'flask plugin upgrade-all'", fg='yellow'))
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
click.echo(click.style(f"Failed to enable {name}", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@plugin_cli.command('disable')
|
||||
@click.argument('name')
|
||||
@with_appcontext
|
||||
def disable_plugin(name: str):
|
||||
"""Disable an enabled plugin."""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
if pm.disable_plugin(name):
|
||||
click.echo(click.style(f"Disabled {name}", fg='green'))
|
||||
else:
|
||||
click.echo(click.style(f"Failed to disable {name}", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@plugin_cli.command('info')
|
||||
@click.argument('name')
|
||||
@with_appcontext
|
||||
def plugin_info(name: str):
|
||||
"""Show detailed information about a plugin."""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
plugin_class = pm.loader.load_plugin_class(name)
|
||||
if not plugin_class:
|
||||
click.echo(click.style(f"Plugin {name} not found", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
try:
|
||||
temp = plugin_class()
|
||||
meta = temp.meta
|
||||
except Exception as e:
|
||||
click.echo(click.style(f"Error loading plugin: {e}", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
state = pm.registry.get(name)
|
||||
|
||||
click.echo("")
|
||||
click.echo("=" * 50)
|
||||
click.echo(click.style(f"Plugin: {meta.name}", fg='cyan', bold=True))
|
||||
click.echo("=" * 50)
|
||||
click.echo(f"Version: {meta.version}")
|
||||
click.echo(f"Description: {meta.description}")
|
||||
click.echo(f"Author: {meta.author or 'Unknown'}")
|
||||
click.echo(f"API Prefix: {meta.api_prefix}")
|
||||
click.echo(f"Dependencies: {', '.join(meta.dependencies) or 'None'}")
|
||||
click.echo(f"Core Version: {meta.core_version}")
|
||||
click.echo("")
|
||||
|
||||
if state:
|
||||
status = click.style('Enabled', fg='green') if state.enabled else click.style('Disabled', fg='yellow')
|
||||
click.echo(f"Status: {status}")
|
||||
click.echo(f"Installed: {state.installed_at}")
|
||||
click.echo(f"Migrations: {len(state.migrations_applied)} applied")
|
||||
else:
|
||||
click.echo(f"Status: {click.style('Not installed', fg='white')}")
|
||||
|
||||
click.echo("")
|
||||
|
||||
|
||||
@plugin_cli.command('new')
|
||||
@click.argument('name')
|
||||
@click.option('--description', default='', help='One-sentence plugin description')
|
||||
@click.option('--overwrite', is_flag=True, help='Overwrite existing plugin directory')
|
||||
@with_appcontext
|
||||
def new_plugin(name: str, description: str, overwrite: bool):
|
||||
"""Scaffold a new plugin from the bundled templates.
|
||||
|
||||
Usage: flask plugin new cameras --description "Tracks shop-floor cameras"
|
||||
"""
|
||||
from pathlib import Path
|
||||
from .scaffolder import scaffold_plugin, ScaffoldError
|
||||
|
||||
plugins_dir = Path(current_app.root_path).parent / 'plugins'
|
||||
|
||||
if not description:
|
||||
description = f'{name.capitalize()} plugin (TODO: replace this description)'
|
||||
|
||||
try:
|
||||
target = scaffold_plugin(
|
||||
name=name,
|
||||
description=description,
|
||||
plugins_dir=plugins_dir,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
except ScaffoldError as e:
|
||||
click.echo(click.style(f'Scaffold failed: {e}', fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
click.echo(click.style(f'Created plugin at {target}', fg='green'))
|
||||
click.echo('')
|
||||
click.echo('Next steps:')
|
||||
click.echo(f' 1. Edit plugins/{name}/models/{name}.py with your domain fields')
|
||||
click.echo(f' 2. Edit plugins/{name}/api/routes.py with your endpoints')
|
||||
click.echo(f' 3. Add plugins/{name}/migrations/ with an Alembic chain that')
|
||||
click.echo(f' creates your tables (per-plugin chain, NOT the core chain;')
|
||||
click.echo(f' see ADR-008). Register the tables in PLUGIN_TABLE_OWNERS.')
|
||||
click.echo(f' 4. Run: flask plugin install {name}')
|
||||
click.echo(f' 5. Run: flask plugin upgrade-all')
|
||||
click.echo(f' 6. Run: pytest plugins/{name}/tests/')
|
||||
|
||||
|
||||
@plugin_cli.command('migrate')
|
||||
@click.argument('name')
|
||||
@click.option('--revision', default='head', help='Target revision')
|
||||
@with_appcontext
|
||||
def migrate_plugin(name: str, revision: str):
|
||||
"""Run migrations for a specific plugin."""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
if not pm.registry.is_installed(name):
|
||||
click.echo(click.style(f"Plugin {name} is not installed", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
click.echo(f"Running migrations for {name}...")
|
||||
|
||||
if pm.migration_manager.run_plugin_migrations(name, revision):
|
||||
click.echo(click.style("Migrations completed", fg='green'))
|
||||
else:
|
||||
click.echo(click.style("Migration failed", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@plugin_cli.command('upgrade-all')
|
||||
@with_appcontext
|
||||
def upgrade_all_plugins():
|
||||
"""Run pending migrations for every discovered plugin.
|
||||
|
||||
Idempotent. Run this after `flask db upgrade` on every deploy and
|
||||
upgrade. It stamps each bundled plugin's anchor revision into
|
||||
alembic_version_<plugin> and applies any per-plugin migrations added
|
||||
after the ownership cutover (ADR-008). Safe to re-run at head.
|
||||
"""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
results = pm.upgrade_all_plugins()
|
||||
if not results:
|
||||
click.echo("No plugins discovered.")
|
||||
return
|
||||
for name, status in sorted(results.items()):
|
||||
if status == 'ok':
|
||||
click.echo(click.style(f" {name:20} ok", fg='green'))
|
||||
elif status == 'no-migrations':
|
||||
click.echo(click.style(f" {name:20} no migrations", fg='yellow'))
|
||||
else:
|
||||
click.echo(click.style(f" {name:20} {status}", fg='red'))
|
||||
276
shopdb/plugins/loader.py
Normal file
276
shopdb/plugins/loader.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Plugin discovery and loading.
|
||||
|
||||
The loader reads each plugin's manifest.json before instantiating the
|
||||
plugin class. Dependency sorting and contract-version compatibility
|
||||
checks operate on manifests, not plugin instances. The plugin class is
|
||||
imported and instantiated only after the manifest passes validation.
|
||||
|
||||
Failure policy (per the enforcing-plugin-contract skill):
|
||||
- In dev/test (app.config.DEBUG or TESTING true): re-raise the original
|
||||
exception so failures are loud.
|
||||
- In production: log the failure with full context and exclude the
|
||||
plugin from registration. The framework keeps booting.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Type, Optional
|
||||
|
||||
from flask import Flask
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
from packaging.version import Version, InvalidVersion
|
||||
|
||||
from .base import BasePlugin
|
||||
from .registry import PluginRegistry
|
||||
from ..exceptions import (
|
||||
PluginError,
|
||||
PluginNotFoundError,
|
||||
PluginContractError,
|
||||
PluginVersionError,
|
||||
PluginDependencyError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginLoader:
|
||||
"""Discovers and loads plugins from the plugins directory."""
|
||||
|
||||
def __init__(self, plugins_dir: Path, registry: PluginRegistry):
|
||||
self.plugins_dir = plugins_dir
|
||||
self.registry = registry
|
||||
self._loaded_plugins: Dict[str, BasePlugin] = {}
|
||||
self._plugin_classes: Dict[str, Type[BasePlugin]] = {}
|
||||
self._manifests: Dict[str, dict] = {}
|
||||
|
||||
def _is_strict_mode(self, app: Optional[Flask]) -> bool:
|
||||
"""Return True if loader should re-raise instead of isolating failures."""
|
||||
if app is None:
|
||||
return True
|
||||
return bool(app.config.get('DEBUG') or app.config.get('TESTING'))
|
||||
|
||||
def _handle_failure(self, app: Optional[Flask], exc: Exception, name: str) -> None:
|
||||
"""In strict mode re-raise; in production log with context and continue."""
|
||||
if self._is_strict_mode(app):
|
||||
raise exc
|
||||
logger.exception(f'Plugin {name} failed to load: {exc}')
|
||||
|
||||
def discover_plugins(self) -> List[str]:
|
||||
"""Discover available plugins in plugins directory."""
|
||||
available = []
|
||||
if not self.plugins_dir.exists():
|
||||
return available
|
||||
|
||||
for item in self.plugins_dir.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
if (item / 'plugin.py').exists():
|
||||
available.append(item.name)
|
||||
|
||||
return available
|
||||
|
||||
def load_manifest(self, name: str) -> dict:
|
||||
"""Load the plugin's manifest.json.
|
||||
|
||||
Raises PluginNotFoundError if the manifest does not exist or is
|
||||
unparseable. The result is cached.
|
||||
"""
|
||||
if name in self._manifests:
|
||||
return self._manifests[name]
|
||||
|
||||
manifest_path = self.plugins_dir / name / 'manifest.json'
|
||||
if not manifest_path.exists():
|
||||
raise PluginNotFoundError(
|
||||
f'Plugin {name} has no manifest.json at {manifest_path}',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
try:
|
||||
with open(manifest_path) as f:
|
||||
manifest = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} has invalid manifest.json: {e}',
|
||||
plugin_name=name,
|
||||
) from e
|
||||
|
||||
for required in ('name', 'version', 'description'):
|
||||
if not manifest.get(required):
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} manifest.json missing required field "{required}"',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
if manifest['name'] != name:
|
||||
raise PluginContractError(
|
||||
f'Plugin directory "{name}" does not match manifest name "{manifest["name"]}"',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
self._manifests[name] = manifest
|
||||
return manifest
|
||||
|
||||
def check_contract_version(self, name: str, contract_version: str) -> None:
|
||||
"""Verify the plugin's core_version range includes contract_version.
|
||||
|
||||
Raises PluginVersionError on mismatch.
|
||||
"""
|
||||
manifest = self.load_manifest(name)
|
||||
core_version_spec = manifest.get('core_version', '')
|
||||
|
||||
if not core_version_spec:
|
||||
return
|
||||
|
||||
try:
|
||||
specifier = SpecifierSet(core_version_spec)
|
||||
framework_version = Version(contract_version)
|
||||
except (InvalidSpecifier, InvalidVersion) as e:
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} has invalid version specifier "{core_version_spec}": {e}',
|
||||
plugin_name=name,
|
||||
) from e
|
||||
|
||||
if framework_version not in specifier:
|
||||
raise PluginVersionError(
|
||||
f'Plugin {name} requires core_version {core_version_spec} '
|
||||
f'but framework is at {contract_version}',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
def load_plugin_class(self, name: str) -> Type[BasePlugin]:
|
||||
"""Import the plugin's plugin.py and return the BasePlugin subclass.
|
||||
|
||||
Raises PluginNotFoundError if plugin.py is missing.
|
||||
Raises PluginContractError if the module has no BasePlugin subclass
|
||||
or import fails.
|
||||
"""
|
||||
if name in self._plugin_classes:
|
||||
return self._plugin_classes[name]
|
||||
|
||||
plugin_module_path = self.plugins_dir / name / 'plugin.py'
|
||||
if not plugin_module_path.exists():
|
||||
raise PluginNotFoundError(
|
||||
f'Plugin {name} plugin.py not found at {plugin_module_path}',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f'plugins.{name}.plugin', plugin_module_path,
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as e:
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} import failed: {e}',
|
||||
plugin_name=name,
|
||||
) from e
|
||||
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if (isinstance(attr, type)
|
||||
and issubclass(attr, BasePlugin)
|
||||
and attr is not BasePlugin):
|
||||
self._plugin_classes[name] = attr
|
||||
return attr
|
||||
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} module has no BasePlugin subclass',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
def load_plugin(self, name: str, app: Flask, db) -> Optional[BasePlugin]:
|
||||
"""Load and instantiate a plugin.
|
||||
|
||||
Returns the plugin instance on success. In strict mode (dev/test),
|
||||
any failure raises. In production, returns None and logs the
|
||||
failure with full context.
|
||||
"""
|
||||
if name in self._loaded_plugins:
|
||||
return self._loaded_plugins[name]
|
||||
|
||||
try:
|
||||
from shopdb import __contract_version__
|
||||
|
||||
manifest = self.load_manifest(name)
|
||||
self.check_contract_version(name, __contract_version__)
|
||||
|
||||
for dep in manifest.get('dependencies', []):
|
||||
if not self.registry.is_enabled(dep):
|
||||
raise PluginDependencyError(
|
||||
f'Plugin {name} requires {dep} which is not enabled',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
plugin_class = self.load_plugin_class(name)
|
||||
plugin = plugin_class()
|
||||
plugin.init_app(app, db)
|
||||
|
||||
self._loaded_plugins[name] = plugin
|
||||
return plugin
|
||||
|
||||
except PluginError as e:
|
||||
self._handle_failure(app, e, name)
|
||||
return None
|
||||
except Exception as e:
|
||||
wrapped = PluginError(
|
||||
f'Unexpected failure loading plugin {name}: {e}',
|
||||
plugin_name=name,
|
||||
)
|
||||
self._handle_failure(app, wrapped, name)
|
||||
return None
|
||||
|
||||
def load_enabled_plugins(self, app: Flask, db) -> Dict[str, BasePlugin]:
|
||||
"""Load all enabled plugins in dependency order."""
|
||||
loaded = {}
|
||||
|
||||
enabled = self.registry.get_enabled_plugins()
|
||||
sorted_plugins = self._sort_by_dependencies(enabled)
|
||||
|
||||
for name in sorted_plugins:
|
||||
plugin = self.load_plugin(name, app, db)
|
||||
if plugin:
|
||||
loaded[name] = plugin
|
||||
logger.info(f'Loaded plugin: {name} v{plugin.meta.version}')
|
||||
|
||||
return loaded
|
||||
|
||||
def _sort_by_dependencies(self, plugin_names: List[str]) -> List[str]:
|
||||
"""Sort plugins so dependencies come first.
|
||||
|
||||
Reads dependencies from manifest.json directly; does not
|
||||
instantiate plugin classes during sort.
|
||||
"""
|
||||
sorted_list = []
|
||||
visited = set()
|
||||
|
||||
def visit(name):
|
||||
if name in visited:
|
||||
return
|
||||
visited.add(name)
|
||||
|
||||
try:
|
||||
manifest = self.load_manifest(name)
|
||||
for dep in manifest.get('dependencies', []):
|
||||
if dep in plugin_names:
|
||||
visit(dep)
|
||||
except PluginError:
|
||||
pass
|
||||
|
||||
sorted_list.append(name)
|
||||
|
||||
for name in plugin_names:
|
||||
visit(name)
|
||||
|
||||
return sorted_list
|
||||
|
||||
def get_loaded_plugin(self, name: str) -> Optional[BasePlugin]:
|
||||
"""Get an already loaded plugin."""
|
||||
return self._loaded_plugins.get(name)
|
||||
|
||||
def get_all_loaded(self) -> Dict[str, BasePlugin]:
|
||||
"""Get all loaded plugins."""
|
||||
return self._loaded_plugins.copy()
|
||||
207
shopdb/plugins/migrations.py
Normal file
207
shopdb/plugins/migrations.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""Plugin migration management using Alembic."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginMigrationManager:
|
||||
"""
|
||||
Manages database migrations for plugins.
|
||||
Each plugin has its own migrations directory.
|
||||
"""
|
||||
|
||||
def __init__(self, plugins_dir: Path, database_url: str):
|
||||
self.plugins_dir = plugins_dir
|
||||
self.database_url = database_url
|
||||
|
||||
def get_migrations_dir(self, plugin_name: str) -> Optional[Path]:
|
||||
"""Get migrations directory for a plugin."""
|
||||
migrations_dir = self.plugins_dir / plugin_name / 'migrations'
|
||||
if migrations_dir.exists():
|
||||
return migrations_dir
|
||||
return None
|
||||
|
||||
def run_plugin_migrations(
|
||||
self,
|
||||
plugin_name: str,
|
||||
revision: str = 'head'
|
||||
) -> bool:
|
||||
"""
|
||||
Run migrations for a plugin.
|
||||
|
||||
Uses flask db upgrade with the plugin's migrations directory.
|
||||
"""
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
|
||||
if not migrations_dir:
|
||||
logger.info(f"No migrations directory for plugin {plugin_name}")
|
||||
return True # No migrations to run
|
||||
|
||||
try:
|
||||
# Use alembic directly with plugin's migrations
|
||||
from alembic.config import Config
|
||||
from alembic import command
|
||||
|
||||
config = Config()
|
||||
config.set_main_option('script_location', str(migrations_dir))
|
||||
config.set_main_option('sqlalchemy.url', self.database_url)
|
||||
|
||||
# Use plugin-specific version table
|
||||
config.set_main_option(
|
||||
'version_table',
|
||||
f'alembic_version_{plugin_name}'
|
||||
)
|
||||
|
||||
command.upgrade(config, revision)
|
||||
logger.info(f"Migrations completed for {plugin_name}")
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
# Fallback to subprocess if alembic not available in context
|
||||
logger.warning("Using subprocess for migrations")
|
||||
return self._run_migrations_subprocess(plugin_name, revision)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Migration failed for {plugin_name}: {e}")
|
||||
return False
|
||||
|
||||
def _run_migrations_subprocess(
|
||||
self,
|
||||
plugin_name: str,
|
||||
revision: str = 'head'
|
||||
) -> bool:
|
||||
"""Run migrations via subprocess as fallback."""
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
if not migrations_dir:
|
||||
return True
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable, '-m', 'alembic',
|
||||
'-c', str(migrations_dir / 'alembic.ini'),
|
||||
'upgrade', revision
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={
|
||||
**dict(__import__('os').environ),
|
||||
'DATABASE_URL': self.database_url
|
||||
}
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Migration error: {result.stderr}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Migration subprocess failed: {e}")
|
||||
return False
|
||||
|
||||
def downgrade_plugin(
|
||||
self,
|
||||
plugin_name: str,
|
||||
revision: str = 'base'
|
||||
) -> bool:
|
||||
"""
|
||||
Downgrade/rollback plugin migrations.
|
||||
"""
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
|
||||
if not migrations_dir:
|
||||
return True
|
||||
|
||||
try:
|
||||
from alembic.config import Config
|
||||
from alembic import command
|
||||
|
||||
config = Config()
|
||||
config.set_main_option('script_location', str(migrations_dir))
|
||||
config.set_main_option('sqlalchemy.url', self.database_url)
|
||||
config.set_main_option(
|
||||
'version_table',
|
||||
f'alembic_version_{plugin_name}'
|
||||
)
|
||||
|
||||
command.downgrade(config, revision)
|
||||
logger.info(f"Downgrade completed for {plugin_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Downgrade failed for {plugin_name}: {e}")
|
||||
return False
|
||||
|
||||
def get_current_revision(self, plugin_name: str) -> Optional[str]:
|
||||
"""Get current migration revision for a plugin."""
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
if not migrations_dir:
|
||||
return None
|
||||
|
||||
try:
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
config = Config()
|
||||
config.set_main_option('script_location', str(migrations_dir))
|
||||
|
||||
script = ScriptDirectory.from_config(config)
|
||||
return script.get_current_head()
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def has_pending_migrations(self, plugin_name: str) -> bool:
|
||||
"""Check if plugin has any migration scripts on disk.
|
||||
|
||||
File-level check only (no DB): True when the plugin ships a
|
||||
migrations/versions dir with at least one script. Used by
|
||||
upgrade-all to decide whether the plugin participates in the
|
||||
per-plugin Alembic flow at all.
|
||||
"""
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
if not migrations_dir:
|
||||
return False
|
||||
|
||||
versions_dir = migrations_dir / 'versions'
|
||||
if not versions_dir.exists():
|
||||
return False
|
||||
|
||||
# Any migration files on disk?
|
||||
migration_files = list(versions_dir.glob('*.py'))
|
||||
return len(migration_files) > 0
|
||||
|
||||
def get_applied_revision(self, plugin_name: str) -> Optional[str]:
|
||||
"""Return the revision stamped in alembic_version_<plugin> in the DB,
|
||||
or None if the plugin chain has never been stamped (or on any error)."""
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
if not migrations_dir or not self.database_url:
|
||||
return None
|
||||
try:
|
||||
from sqlalchemy import create_engine
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
|
||||
engine = create_engine(self.database_url)
|
||||
with engine.connect() as connection:
|
||||
context = MigrationContext.configure(
|
||||
connection,
|
||||
opts={'version_table': f'alembic_version_{plugin_name}'},
|
||||
)
|
||||
return context.get_current_revision()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def has_unapplied_migrations(self, plugin_name: str) -> bool:
|
||||
"""True when the plugin's on-disk chain head is ahead of what the DB
|
||||
has stamped. Compares the script head against alembic_version_<plugin>.
|
||||
Best-effort: returns False when it cannot tell (no chain / no DB)."""
|
||||
head = self.get_current_revision(plugin_name) # script head on disk
|
||||
if not head:
|
||||
return False
|
||||
return self.get_applied_revision(plugin_name) != head
|
||||
140
shopdb/plugins/registry.py
Normal file
140
shopdb/plugins/registry.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Plugin registry for tracking installed and enabled plugins."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginState:
|
||||
"""Persistent state for a plugin."""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
installed_at: str
|
||||
enabled: bool = True
|
||||
migrations_applied: List[str] = field(default_factory=list)
|
||||
config: Dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class PluginRegistry:
|
||||
"""
|
||||
Manages plugin state persistence.
|
||||
Stores state in JSON file in instance folder.
|
||||
"""
|
||||
|
||||
def __init__(self, state_file: Path):
|
||||
self.state_file = state_file
|
||||
self._plugins: Dict[str, PluginState] = {}
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load registry from file."""
|
||||
if self.state_file.exists():
|
||||
try:
|
||||
with open(self.state_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
for name, state_data in data.get('plugins', {}).items():
|
||||
self._plugins[name] = PluginState(**state_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# Corrupted file, start fresh
|
||||
self._plugins = {}
|
||||
self._migrate_renamed_plugins()
|
||||
|
||||
def _migrate_renamed_plugins(self) -> None:
|
||||
"""Upgrade path for the equipment -> machines plugin rename.
|
||||
|
||||
Existing installs carry an 'equipment' entry in plugins.json. When the
|
||||
renamed plugins/machines directory exists and no 'machines' entry does,
|
||||
carry the state over under the new name and persist.
|
||||
"""
|
||||
if 'equipment' not in self._plugins or 'machines' in self._plugins:
|
||||
return
|
||||
machines_dir = Path(__file__).resolve().parents[2] / 'plugins' / 'machines'
|
||||
if not machines_dir.exists():
|
||||
return
|
||||
state = self._plugins.pop('equipment')
|
||||
state.name = 'machines'
|
||||
self._plugins['machines'] = state
|
||||
self._save()
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Save registry to file."""
|
||||
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.state_file, 'w') as f:
|
||||
json.dump({
|
||||
'plugins': {
|
||||
name: asdict(state)
|
||||
for name, state in self._plugins.items()
|
||||
}
|
||||
}, f, indent=2)
|
||||
|
||||
def register(self, name: str, version: str, enabled: bool = True) -> PluginState:
|
||||
"""Register a newly installed plugin. enabled=False leaves it off until a
|
||||
site opts in (used for plugins that provision extra tables)."""
|
||||
state = PluginState(
|
||||
name=name,
|
||||
version=version,
|
||||
installed_at=datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
|
||||
enabled=enabled
|
||||
)
|
||||
self._plugins[name] = state
|
||||
self._save()
|
||||
return state
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""Remove plugin from registry."""
|
||||
if name in self._plugins:
|
||||
del self._plugins[name]
|
||||
self._save()
|
||||
|
||||
def get(self, name: str) -> Optional[PluginState]:
|
||||
"""Get plugin state."""
|
||||
return self._plugins.get(name)
|
||||
|
||||
def is_installed(self, name: str) -> bool:
|
||||
"""Check if plugin is installed."""
|
||||
return name in self._plugins
|
||||
|
||||
def is_enabled(self, name: str) -> bool:
|
||||
"""Check if plugin is enabled."""
|
||||
state = self._plugins.get(name)
|
||||
return state.enabled if state else False
|
||||
|
||||
def enable(self, name: str) -> None:
|
||||
"""Enable a plugin."""
|
||||
if name in self._plugins:
|
||||
self._plugins[name].enabled = True
|
||||
self._save()
|
||||
|
||||
def disable(self, name: str) -> None:
|
||||
"""Disable a plugin."""
|
||||
if name in self._plugins:
|
||||
self._plugins[name].enabled = False
|
||||
self._save()
|
||||
|
||||
def get_enabled_plugins(self) -> List[str]:
|
||||
"""Get list of enabled plugin names."""
|
||||
return [
|
||||
name for name, state in self._plugins.items()
|
||||
if state.enabled
|
||||
]
|
||||
|
||||
def add_migration(self, name: str, revision: str) -> None:
|
||||
"""Record that a migration was applied."""
|
||||
if name in self._plugins:
|
||||
if revision not in self._plugins[name].migrations_applied:
|
||||
self._plugins[name].migrations_applied.append(revision)
|
||||
self._save()
|
||||
|
||||
def get_all(self) -> Dict[str, PluginState]:
|
||||
"""Get all registered plugins."""
|
||||
return self._plugins.copy()
|
||||
|
||||
def update_config(self, name: str, config: Dict) -> None:
|
||||
"""Update plugin configuration."""
|
||||
if name in self._plugins:
|
||||
self._plugins[name].config.update(config)
|
||||
self._save()
|
||||
236
shopdb/plugins/scaffolder.py
Normal file
236
shopdb/plugins/scaffolder.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Plugin scaffolder.
|
||||
|
||||
Generates a new plugin skeleton from the templates under
|
||||
shopdb/plugins/templates/. The generated plugin satisfies the framework
|
||||
contract out of the box; tests/test_plugin_scaffold.py is the canary
|
||||
that this stays true as the contract evolves.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
from typing import Optional
|
||||
|
||||
|
||||
TEMPLATE_ROOT = Path(__file__).parent / 'templates'
|
||||
|
||||
VALID_NAME_PATTERN = re.compile(r'^[a-z][a-z0-9]*$')
|
||||
|
||||
RESERVED_NAMES = {
|
||||
'plugin', 'plugins', 'shopdb', 'core', 'api', 'tests', 'templates',
|
||||
'schemas', 'models', 'frontend', 'docs', 'migrations', 'scripts',
|
||||
}
|
||||
|
||||
|
||||
class ScaffoldError(Exception):
|
||||
"""Raised when scaffolding cannot proceed."""
|
||||
|
||||
|
||||
def validate_name(name: str) -> None:
|
||||
"""Validate plugin name against CONTRIBUTING.md and reserved list.
|
||||
|
||||
Raises ScaffoldError on any violation.
|
||||
"""
|
||||
if not name:
|
||||
raise ScaffoldError('Plugin name is required')
|
||||
|
||||
if not VALID_NAME_PATTERN.match(name):
|
||||
raise ScaffoldError(
|
||||
f'Plugin name "{name}" must be lowercase letters and digits only, '
|
||||
f'starting with a letter (no hyphens, underscores, or special chars). '
|
||||
f'See CONTRIBUTING.md for the naming convention.'
|
||||
)
|
||||
|
||||
if name in RESERVED_NAMES:
|
||||
raise ScaffoldError(
|
||||
f'Plugin name "{name}" is reserved. Pick a different name.'
|
||||
)
|
||||
|
||||
|
||||
def pascal_case(name: str) -> str:
|
||||
"""Convert lowercase plugin name to PascalCase class name.
|
||||
|
||||
'cameras' -> 'Cameras'. The convention assumes single-word lowercase
|
||||
plugin names per the naming rules in CONTRIBUTING.md, so this is
|
||||
just title-casing.
|
||||
"""
|
||||
return name[:1].upper() + name[1:]
|
||||
|
||||
|
||||
def _render_template(
|
||||
template_path: Path,
|
||||
out_path: Path,
|
||||
substitutions: dict,
|
||||
overwrite: bool,
|
||||
) -> bool:
|
||||
"""Render one template to out_path.
|
||||
|
||||
Skips silently when out_path already exists and overwrite is False, so a
|
||||
scaffold never clobbers a file the author has already edited. Returns True
|
||||
when the file was written, False when it was skipped.
|
||||
"""
|
||||
if out_path.exists() and not overwrite:
|
||||
return False
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
body = template_path.read_text()
|
||||
out_path.write_text(Template(body).safe_substitute(substitutions))
|
||||
return True
|
||||
|
||||
|
||||
def _scaffold_frontend(
|
||||
name: str,
|
||||
substitutions: dict,
|
||||
plugin_target: Path,
|
||||
frontend_dir: Path,
|
||||
template_root: Path,
|
||||
overwrite: bool,
|
||||
) -> None:
|
||||
"""Render the frontend starting points for a scaffolded plugin.
|
||||
|
||||
Writes the paste-in api-client snippet into the plugin directory, then the
|
||||
Vue views and the router route file into the real frontend tree. The
|
||||
snippet is emitted regardless of whether the frontend tree exists, because
|
||||
it is a plugin-directory artifact useful even for external-repo plugins.
|
||||
Views and the route file are skipped when frontend_dir is missing, which is
|
||||
the normal case for a plugin developed in its own repository.
|
||||
|
||||
Ordering matters: every view is written before the route file. A route file
|
||||
that lazy-imports a view that is not on disk crashes the Vite dev server,
|
||||
so the views must land first.
|
||||
"""
|
||||
fe_templates = template_root / 'frontend'
|
||||
if not fe_templates.exists():
|
||||
return
|
||||
|
||||
plugin_name = substitutions['Name']
|
||||
|
||||
# snippet lands in the plugin dir; author pastes it into api/index.js
|
||||
snippet_template = fe_templates / 'frontend-api-snippet.js.tmpl'
|
||||
if snippet_template.exists():
|
||||
_render_template(
|
||||
snippet_template,
|
||||
plugin_target / 'frontend-api-snippet.js',
|
||||
substitutions,
|
||||
overwrite,
|
||||
)
|
||||
|
||||
# views and route need the real frontend tree; external repos skip these
|
||||
if not frontend_dir.exists():
|
||||
return
|
||||
|
||||
views_dir = frontend_dir / 'views' / name
|
||||
|
||||
# views first: route file lazy-imports them, missing views 500 vite
|
||||
view_templates = {
|
||||
'List.vue.tmpl': f'{plugin_name}List.vue',
|
||||
'Detail.vue.tmpl': f'{plugin_name}Detail.vue',
|
||||
'Form.vue.tmpl': f'{plugin_name}Form.vue',
|
||||
}
|
||||
for template_name, out_name in view_templates.items():
|
||||
template_path = fe_templates / 'views' / template_name
|
||||
if template_path.exists():
|
||||
_render_template(
|
||||
template_path,
|
||||
views_dir / out_name,
|
||||
substitutions,
|
||||
overwrite,
|
||||
)
|
||||
|
||||
# route file last: all referenced views now exist on disk
|
||||
route_template = fe_templates / 'routes.js.tmpl'
|
||||
if route_template.exists():
|
||||
_render_template(
|
||||
route_template,
|
||||
frontend_dir / 'router' / 'routes' / f'{name}.js',
|
||||
substitutions,
|
||||
overwrite,
|
||||
)
|
||||
|
||||
|
||||
def scaffold_plugin(
|
||||
name: str,
|
||||
description: str,
|
||||
plugins_dir: Path,
|
||||
template_root: Optional[Path] = None,
|
||||
overwrite: bool = False,
|
||||
frontend: bool = True,
|
||||
frontend_dir: Optional[Path] = None,
|
||||
) -> Path:
|
||||
"""Generate a new plugin from templates.
|
||||
|
||||
Args:
|
||||
name: Plugin name (lowercase, single word)
|
||||
description: One-sentence description for manifest.json + README
|
||||
plugins_dir: Target plugins directory (e.g., <repo>/plugins)
|
||||
template_root: Override template source dir (default: bundled templates)
|
||||
overwrite: If True, overwrite an existing plugin directory and any
|
||||
existing generated frontend files
|
||||
frontend: If True, also render the Vue frontend starting points (list,
|
||||
detail, form views, a route file, and a paste-in api-client snippet)
|
||||
frontend_dir: Frontend src directory to render views/routes into
|
||||
(default: <plugins_dir>/../frontend/src). Views and the route file
|
||||
are skipped when this directory does not exist, which is the normal
|
||||
case for a plugin developed in its own repository.
|
||||
|
||||
Returns:
|
||||
Path to the generated plugin directory.
|
||||
|
||||
Raises:
|
||||
ScaffoldError on validation failure or when target exists and
|
||||
overwrite is False.
|
||||
"""
|
||||
validate_name(name)
|
||||
|
||||
template_root = template_root or TEMPLATE_ROOT
|
||||
if not template_root.exists():
|
||||
raise ScaffoldError(f'Template root not found: {template_root}')
|
||||
|
||||
target = plugins_dir / name
|
||||
|
||||
if target.exists():
|
||||
if not overwrite:
|
||||
raise ScaffoldError(
|
||||
f'Plugin directory already exists: {target}. '
|
||||
f'Pass overwrite=True or remove it first.'
|
||||
)
|
||||
|
||||
substitutions = {
|
||||
'name': name,
|
||||
'Name': pascal_case(name),
|
||||
'description': description,
|
||||
}
|
||||
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for template_path in template_root.rglob('*.tmpl'):
|
||||
rel = template_path.relative_to(template_root)
|
||||
|
||||
# frontend templates render into the frontend tree, not the plugin dir
|
||||
if rel.parts and rel.parts[0] == 'frontend':
|
||||
continue
|
||||
|
||||
out_rel_str = str(rel.with_suffix(''))
|
||||
if 'model.py' in out_rel_str:
|
||||
out_rel_str = out_rel_str.replace('model.py', f'{name}.py')
|
||||
|
||||
out_path = target / out_rel_str
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
body = template_path.read_text()
|
||||
rendered = Template(body).safe_substitute(substitutions)
|
||||
|
||||
out_path.write_text(rendered)
|
||||
|
||||
if frontend:
|
||||
if frontend_dir is None:
|
||||
frontend_dir = plugins_dir.parent / 'frontend' / 'src'
|
||||
_scaffold_frontend(
|
||||
name=name,
|
||||
substitutions=substitutions,
|
||||
plugin_target=target,
|
||||
frontend_dir=Path(frontend_dir),
|
||||
template_root=template_root,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
|
||||
return target
|
||||
43
shopdb/plugins/templates/README.md.tmpl
Normal file
43
shopdb/plugins/templates/README.md.tmpl
Normal file
@@ -0,0 +1,43 @@
|
||||
# $Name plugin
|
||||
|
||||
$description
|
||||
|
||||
This plugin was generated by `flask plugin new $name`. It satisfies the framework contract out of the box. Replace the example model and routes with your domain.
|
||||
|
||||
## What's here
|
||||
|
||||
- `plugin.py` - the `${Name}Plugin` class extending `BasePlugin`. Edit `init_app` for custom setup, `on_install` to seed reference data.
|
||||
- `models/$name.py` - example Asset extension table. Replace `examplefield` with your domain fields.
|
||||
- `api/routes.py` - example list and detail endpoints. Add CRUD as needed.
|
||||
- `schemas/__init__.py` - marshmallow schema stub for request/response validation.
|
||||
- `tests/test_plugin.py` - smoke tests asserting contract compliance.
|
||||
- `manifest.json` - plugin metadata. Bump `version` on changes; keep `core_version` range broad.
|
||||
|
||||
## Common edits
|
||||
|
||||
| You want to... | Do this |
|
||||
|---|---|
|
||||
| Add a hook (search, navigation, dashboard widget) | Override the method in `${Name}Plugin`. See the PLUGIN-HOOKS wiki page. |
|
||||
| Accept external collector data | Override `get_collector_schema()` to return a JSON Schema. See ADR-006. |
|
||||
| Add another model | Create `models/<other>.py`, export it in `models/__init__.py`, return it in `get_models()`. |
|
||||
| Add a CLI command | Override `get_cli_commands()` returning a list of Click commands. |
|
||||
|
||||
## Frontend
|
||||
|
||||
Vue components for this plugin live under `frontend/src/views/$name/` (per project convention). Backend scaffolding does not generate frontend yet; copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) as a starting point.
|
||||
|
||||
## Install and run
|
||||
|
||||
```bash
|
||||
flask plugin install $name
|
||||
flask db migrate -m "Add $name plugin tables"
|
||||
flask db upgrade
|
||||
pytest plugins/$name/tests/
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- the PLUGIN-HOOKS wiki page - canonical hook reference
|
||||
- the PLUGIN-QUICKSTART wiki page - 30-minute walkthrough
|
||||
- `migrations/adr/ADR-001-asset-as-platform-contract.md` - the platform contract
|
||||
- `migrations/adr/ADR-002-plugin-versioning.md` - versioning rules
|
||||
5
shopdb/plugins/templates/__init__.py.tmpl
Normal file
5
shopdb/plugins/templates/__init__.py.tmpl
Normal file
@@ -0,0 +1,5 @@
|
||||
"""$Name plugin package."""
|
||||
|
||||
from .plugin import ${Name}Plugin
|
||||
|
||||
__all__ = ['${Name}Plugin']
|
||||
5
shopdb/plugins/templates/api/__init__.py.tmpl
Normal file
5
shopdb/plugins/templates/api/__init__.py.tmpl
Normal file
@@ -0,0 +1,5 @@
|
||||
"""$Name plugin API package."""
|
||||
|
||||
from .routes import ${name}_bp
|
||||
|
||||
__all__ = ['${name}_bp']
|
||||
45
shopdb/plugins/templates/api/routes.py.tmpl
Normal file
45
shopdb/plugins/templates/api/routes.py.tmpl
Normal file
@@ -0,0 +1,45 @@
|
||||
"""$Name plugin API routes."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
|
||||
from ..models import $Name
|
||||
|
||||
|
||||
${name}_bp = Blueprint('$name', __name__)
|
||||
|
||||
|
||||
@${name}_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_${name}():
|
||||
"""List $name assets, paginated."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = ${Name}.query
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [item.to_dict() for item in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@${name}_bp.route('/<int:assetid>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_${name}(assetid: int):
|
||||
"""Get a single $name by assetid."""
|
||||
item = ${Name}.query.get(assetid)
|
||||
if not item:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'$Name with assetid {assetid} not found',
|
||||
http_code=404,
|
||||
)
|
||||
return success_response(item.to_dict())
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* ${Name} API client snippet.
|
||||
*
|
||||
* Paste this ${name}Api block into frontend/src/api/index.js (next to the
|
||||
* other per-resource blocks). Then, in the generated ${Name}List, ${Name}Detail,
|
||||
* and ${Name}Form views, delete the local ${name}Api const and import the shared
|
||||
* one instead:
|
||||
*
|
||||
* import { ${name}Api } from '../../api'
|
||||
*
|
||||
* The scaffolded views ship with an identical inline client so they build and
|
||||
* run before you touch the shared api module. This file is NOT auto-merged into
|
||||
* api/index.js on purpose; that module is hand-maintained and shared.
|
||||
*
|
||||
* The create/update/delete calls assume matching POST/PUT/DELETE routes exist
|
||||
* on the backend. The scaffolded api/routes.py only ships list and get; add the
|
||||
* write endpoints when you wire up the form.
|
||||
*/
|
||||
export const ${name}Api = {
|
||||
list(params = {}) {
|
||||
return api.get('/${name}', { params })
|
||||
},
|
||||
get(itemId) {
|
||||
return api.get(`/${name}/${itemId}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/${name}', data)
|
||||
},
|
||||
update(itemId, data) {
|
||||
return api.put(`/${name}/${itemId}`, data)
|
||||
},
|
||||
remove(itemId) {
|
||||
return api.delete(`/${name}/${itemId}`)
|
||||
}
|
||||
}
|
||||
35
shopdb/plugins/templates/frontend/routes.js.tmpl
Normal file
35
shopdb/plugins/templates/frontend/routes.js.tmpl
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* ${Name} plugin routes.
|
||||
*
|
||||
* Auto-discovered by the router via import.meta.glob, so no registration
|
||||
* edit is needed. Every route carries meta.plugin '${name}' so the ADR-009
|
||||
* guard redirects to the dashboard when the ${name} backend plugin is
|
||||
* disabled. Form routes add requiresAuth so anonymous users cannot reach
|
||||
* create or edit.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: '${name}',
|
||||
name: '${name}',
|
||||
component: () => import('../../views/${name}/${Name}List.vue'),
|
||||
meta: { plugin: '${name}' }
|
||||
},
|
||||
{
|
||||
path: '${name}/new',
|
||||
name: '${name}-new',
|
||||
component: () => import('../../views/${name}/${Name}Form.vue'),
|
||||
meta: { requiresAuth: true, plugin: '${name}' }
|
||||
},
|
||||
{
|
||||
path: '${name}/:id',
|
||||
name: '${name}-detail',
|
||||
component: () => import('../../views/${name}/${Name}Detail.vue'),
|
||||
meta: { plugin: '${name}' }
|
||||
},
|
||||
{
|
||||
path: '${name}/:id/edit',
|
||||
name: '${name}-edit',
|
||||
component: () => import('../../views/${name}/${Name}Form.vue'),
|
||||
meta: { requiresAuth: true, plugin: '${name}' }
|
||||
}
|
||||
]
|
||||
149
shopdb/plugins/templates/frontend/views/Detail.vue.tmpl
Normal file
149
shopdb/plugins/templates/frontend/views/Detail.vue.tmpl
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="detail-page" v-if="item">
|
||||
<div class="hero-card">
|
||||
<div class="hero-content">
|
||||
<div class="hero-title-row">
|
||||
<h1 class="hero-title">{{ item.name || item.assetnumber || '${Name}' }}</h1>
|
||||
<router-link
|
||||
v-if="authStore.isAuthenticated"
|
||||
:to="`/${name}/${itemId}/edit`"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Edit
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="detail-item" v-if="item.assetnumber">
|
||||
<span class="label">Asset #</span>
|
||||
<span class="value">{{ item.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="item.serialnumber">
|
||||
<span class="label">Serial</span>
|
||||
<span class="value mono">{{ item.serialnumber }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">${Name} Information</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Example Field</span>
|
||||
<span class="info-value">{{ item.examplefield || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Asset Information</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Asset Number</span>
|
||||
<span class="info-value">{{ item.assetnumber || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ item.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ item.serialnumber || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-bar" v-if="authStore.isAuthenticated">
|
||||
<router-link :to="`/${name}/${itemId}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmDelete" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="loading-container">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="error-container">
|
||||
<p>Record not found</p>
|
||||
<router-link to="/${name}" class="btn btn-secondary">Back to ${Name}</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '../../api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
// local api client. move into src/api/index.js (see
|
||||
// plugins/${name}/frontend-api-snippet.js) then swap for:
|
||||
// import { ${name}Api } from '../../api'
|
||||
const ${name}Api = {
|
||||
list(params = {}) { return api.get('/${name}', { params }) },
|
||||
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
||||
create(data) { return api.post('/${name}', data) },
|
||||
update(itemId, data) { return api.put(`/${name}/${itemId}`, data) },
|
||||
remove(itemId) { return api.delete(`/${name}/${itemId}`) }
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const itemId = route.params.id
|
||||
const item = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(loadItem)
|
||||
|
||||
async function loadItem() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await ${name}Api.get(itemId)
|
||||
item.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading ${name}:', error)
|
||||
item.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (confirm('Delete this record?')) {
|
||||
try {
|
||||
await ${name}Api.remove(itemId)
|
||||
router.push('/${name}')
|
||||
} catch (error) {
|
||||
console.error('Error deleting ${name}:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.error-container {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
228
shopdb/plugins/templates/frontend/views/Form.vue.tmpl
Normal file
228
shopdb/plugins/templates/frontend/views/Form.vue.tmpl
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit ${Name}' : 'Add ${Name}' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<form @submit.prevent="submitForm">
|
||||
<fieldset>
|
||||
<legend>Asset Information</legend>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="assetnumber">Asset Number *</label>
|
||||
<input
|
||||
id="assetnumber"
|
||||
v-model="form.assetnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number</label>
|
||||
<input
|
||||
id="serialnumber"
|
||||
v-model="form.serialnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>${Name} Details</legend>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="examplefield">Example Field</label>
|
||||
<input
|
||||
id="examplefield"
|
||||
v-model="form.examplefield"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="cancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : (isEdit ? 'Save Changes' : 'Create') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '../../api'
|
||||
|
||||
// local api client. move into src/api/index.js (see
|
||||
// plugins/${name}/frontend-api-snippet.js) then swap for:
|
||||
// import { ${name}Api } from '../../api'
|
||||
const ${name}Api = {
|
||||
list(params = {}) { return api.get('/${name}', { params }) },
|
||||
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
||||
create(data) { return api.post('/${name}', data) },
|
||||
update(itemId, data) { return api.put(`/${name}/${itemId}`, data) },
|
||||
remove(itemId) { return api.delete(`/${name}/${itemId}`) }
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const itemId = route.params.id
|
||||
const isEdit = computed(() => !!itemId)
|
||||
|
||||
const form = ref({
|
||||
assetnumber: '',
|
||||
name: '',
|
||||
serialnumber: '',
|
||||
examplefield: ''
|
||||
})
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value) {
|
||||
await loadItem()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadItem() {
|
||||
try {
|
||||
const response = await ${name}Api.get(itemId)
|
||||
const data = response.data.data
|
||||
form.value.assetnumber = data.assetnumber || ''
|
||||
form.value.name = data.name || ''
|
||||
form.value.serialnumber = data.serialnumber || ''
|
||||
form.value.examplefield = data.examplefield || ''
|
||||
} catch (loadError) {
|
||||
console.error('Error loading ${name}:', loadError)
|
||||
error.value = 'Failed to load record'
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
assetnumber: form.value.assetnumber,
|
||||
name: form.value.name || null,
|
||||
serialnumber: form.value.serialnumber || null,
|
||||
examplefield: form.value.examplefield || null
|
||||
}
|
||||
|
||||
let redirectId = itemId
|
||||
if (isEdit.value) {
|
||||
await ${name}Api.update(itemId, payload)
|
||||
} else {
|
||||
const response = await ${name}Api.create(payload)
|
||||
redirectId = response.data.data?.assetid
|
||||
}
|
||||
|
||||
router.push(redirectId ? `/${name}/${redirectId}` : '/${name}')
|
||||
} catch (submitError) {
|
||||
console.error('Error saving ${name}:', submitError)
|
||||
error.value = 'Failed to save record'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (isEdit.value) {
|
||||
router.push(`/${name}/${itemId}`)
|
||||
} else {
|
||||
router.push('/${name}')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
legend {
|
||||
font-weight: 600;
|
||||
padding: 0 0.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
margin-bottom: 0.375rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
142
shopdb/plugins/templates/frontend/views/List.vue.tmpl
Normal file
142
shopdb/plugins/templates/frontend/views/List.vue.tmpl
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>${Name}</h2>
|
||||
<router-link to="/${name}/new" class="btn btn-primary">Add ${Name}</router-link>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset Tag</th>
|
||||
<th>Name</th>
|
||||
<th>Example Field</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.assetid">
|
||||
<td>{{ item.assetnumber || '-' }}</td>
|
||||
<td>{{ item.name || '-' }}</td>
|
||||
<td>{{ item.examplefield || '-' }}</td>
|
||||
<td class="actions">
|
||||
<router-link
|
||||
:to="`/${name}/${item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No ${name} records found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
|
||||
// local api client. move into src/api/index.js (see
|
||||
// plugins/${name}/frontend-api-snippet.js) then swap for:
|
||||
// import { ${name}Api } from '../../api'
|
||||
const ${name}Api = {
|
||||
list(params = {}) { return api.get('/${name}', { params }) },
|
||||
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
||||
create(data) { return api.post('/${name}', data) },
|
||||
update(itemId, data) { return api.put(`/${name}/${itemId}`, data) },
|
||||
remove(itemId) { return api.delete(`/${name}/${itemId}`) }
|
||||
}
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(25)
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
const response = await ${name}Api.list(params)
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading ${name}:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadItems()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(target) {
|
||||
if (target >= 1 && target <= totalPages.value) {
|
||||
page.value = target
|
||||
loadItems()
|
||||
}
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
loadItems()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters .form-control {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
</style>
|
||||
9
shopdb/plugins/templates/manifest.json.tmpl
Normal file
9
shopdb/plugins/templates/manifest.json.tmpl
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "$name",
|
||||
"version": "0.1.0",
|
||||
"description": "$description",
|
||||
"author": "",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/$name"
|
||||
}
|
||||
5
shopdb/plugins/templates/models/__init__.py.tmpl
Normal file
5
shopdb/plugins/templates/models/__init__.py.tmpl
Normal file
@@ -0,0 +1,5 @@
|
||||
"""$Name plugin models."""
|
||||
|
||||
from .$name import $Name
|
||||
|
||||
__all__ = ['$Name']
|
||||
32
shopdb/plugins/templates/models/model.py.tmpl
Normal file
32
shopdb/plugins/templates/models/model.py.tmpl
Normal file
@@ -0,0 +1,32 @@
|
||||
"""$Name model.
|
||||
|
||||
This is an Asset extension table keyed by assetid. The Asset row holds
|
||||
the platform fields (assetnumber, name, vendorid, locationid, etc.);
|
||||
this table holds the $name-specific fields. Replace the example fields
|
||||
below with your domain model.
|
||||
"""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class $Name(BaseModel):
|
||||
"""$Name domain entity, extending Asset by assetid."""
|
||||
|
||||
__tablename__ = '$name'
|
||||
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
# TODO: replace these example fields with your domain fields.
|
||||
examplefield = db.Column(db.String(255), nullable=True)
|
||||
|
||||
asset = db.relationship('Asset', backref=db.backref('$name', uselist=False))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'assetid': self.assetid,
|
||||
'examplefield': self.examplefield,
|
||||
}
|
||||
70
shopdb/plugins/templates/plugin.py.tmpl
Normal file
70
shopdb/plugins/templates/plugin.py.tmpl
Normal file
@@ -0,0 +1,70 @@
|
||||
"""$Name plugin main class.
|
||||
|
||||
$description
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, AssetType
|
||||
|
||||
from .models import $Name
|
||||
from .api import ${name}_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ${Name}Plugin(BasePlugin):
|
||||
"""$Name plugin.
|
||||
|
||||
$description
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
with open(manifest_path) as f:
|
||||
self._manifest = json.load(f)
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
return PluginMeta(
|
||||
name=self._manifest['name'],
|
||||
version=self._manifest['version'],
|
||||
description=self._manifest['description'],
|
||||
author=self._manifest.get('author', ''),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=0.1.0'),
|
||||
api_prefix=self._manifest.get('api_prefix'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
return ${name}_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
return [$Name]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f'$Name plugin initialized (v{self.meta.version})')
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
logger.info('$Name plugin installed')
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
existing = AssetType.query.filter_by(assettype='$name').first()
|
||||
if not existing:
|
||||
asset_type = AssetType(
|
||||
assettype='$name',
|
||||
pluginname='$name',
|
||||
tablename='$name',
|
||||
description=self.meta.description,
|
||||
)
|
||||
db.session.add(asset_type)
|
||||
db.session.commit()
|
||||
logger.debug('Created asset type: $name')
|
||||
6
shopdb/plugins/templates/schemas/__init__.py.tmpl
Normal file
6
shopdb/plugins/templates/schemas/__init__.py.tmpl
Normal file
@@ -0,0 +1,6 @@
|
||||
"""$Name plugin schemas (marshmallow).
|
||||
|
||||
Add schema classes here when you need request/response validation
|
||||
beyond the simple to_dict() output. The framework wires marshmallow
|
||||
into the response helpers; see the PLUGIN-HOOKS wiki page for details.
|
||||
"""
|
||||
0
shopdb/plugins/templates/tests/__init__.py.tmpl
Normal file
0
shopdb/plugins/templates/tests/__init__.py.tmpl
Normal file
30
shopdb/plugins/templates/tests/test_plugin.py.tmpl
Normal file
30
shopdb/plugins/templates/tests/test_plugin.py.tmpl
Normal file
@@ -0,0 +1,30 @@
|
||||
"""$Name plugin smoke tests.
|
||||
|
||||
Asserts the plugin loads cleanly and satisfies the framework contract.
|
||||
Replace and extend with domain tests as you build the plugin out.
|
||||
"""
|
||||
|
||||
from plugins.$name.plugin import ${Name}Plugin
|
||||
|
||||
|
||||
def test_${name}_plugin_meta_is_valid():
|
||||
"""${Name}Plugin.meta returns a PluginMeta with the expected name."""
|
||||
plugin = ${Name}Plugin()
|
||||
assert plugin.meta.name == '$name'
|
||||
assert plugin.meta.api_prefix == '/api/$name'
|
||||
|
||||
|
||||
def test_${name}_plugin_get_blueprint_returns_blueprint():
|
||||
"""get_blueprint returns a Flask Blueprint, not None."""
|
||||
from flask import Blueprint
|
||||
plugin = ${Name}Plugin()
|
||||
assert isinstance(plugin.get_blueprint(), Blueprint)
|
||||
|
||||
|
||||
def test_${name}_plugin_get_models_returns_a_model():
|
||||
"""get_models returns a list with at least one SQLAlchemy model."""
|
||||
plugin = ${Name}Plugin()
|
||||
models = plugin.get_models()
|
||||
assert len(models) >= 1
|
||||
for model in models:
|
||||
assert hasattr(model, '__tablename__')
|
||||
11
shopdb/static/images/floorplan-placeholder.svg
Normal file
11
shopdb/static/images/floorplan-placeholder.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="3300" height="2550" viewBox="0 0 3300 2550">
|
||||
<defs>
|
||||
<pattern id="grid" width="150" height="150" patternUnits="userSpaceOnUse">
|
||||
<path d="M 150 0 L 0 0 0 150" fill="none" stroke="#d0d4d9" stroke-width="2"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="3300" height="2550" fill="#f4f6f8"/>
|
||||
<rect width="3300" height="2550" fill="url(#grid)"/>
|
||||
<rect x="20" y="20" width="3260" height="2510" fill="none" stroke="#b8bec6" stroke-width="4"/>
|
||||
<text x="1650" y="1275" fill="#8a9099" font-family="Arial, Helvetica, sans-serif" font-size="90" font-weight="600" text-anchor="middle" dominant-baseline="central">Upload your facility floor plan in Settings > Map</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 726 B |
20
shopdb/utils/__init__.py
Normal file
20
shopdb/utils/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Utility modules."""
|
||||
|
||||
from .responses import (
|
||||
api_response,
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from .pagination import get_pagination_params, paginate_query
|
||||
|
||||
__all__ = [
|
||||
'api_response',
|
||||
'success_response',
|
||||
'error_response',
|
||||
'paginated_response',
|
||||
'ErrorCodes',
|
||||
'get_pagination_params',
|
||||
'paginate_query'
|
||||
]
|
||||
153
shopdb/utils/apitoken_auth.py
Normal file
153
shopdb/utils/apitoken_auth.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Personal API token (PAT) authentication shim.
|
||||
|
||||
A request may send `Authorization: Bearer shopdb_pat_...`. This is recognized
|
||||
BEFORE any JWT decode: a before_request hook validates the PAT (hash lookup,
|
||||
active, not expired, active owner) and, on success, mints a short internal
|
||||
request-scoped JWT for the token's user and swaps it into the request's
|
||||
Authorization header.
|
||||
|
||||
Why mint a JWT instead of only stashing the user on g: every write route in
|
||||
this app stacks a mandatory @jwt_required() ABOVE @require_permission /
|
||||
@require_role. That mandatory decorator decodes the Authorization header
|
||||
itself, so the ONLY way a PAT reaches the whole existing auth+authz stack
|
||||
(jwt_required, require_permission, require_role, import_mode, current_user,
|
||||
get_jwt_identity) unchanged is to present a genuine JWT downstream. The minted
|
||||
token lives only in this request's environ and is never returned to the client.
|
||||
|
||||
Result: a PAT authenticates any route a login JWT would, acting as its owner,
|
||||
with zero changes to the authz decorators or import-mode helpers.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import g, request
|
||||
from flask_jwt_extended import create_access_token
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX
|
||||
from shopdb.utils.responses import error_response, ErrorCodes
|
||||
|
||||
|
||||
# Only rewrite lastusedat when it is older than this, to avoid a DB write on
|
||||
# every single request a busy integration makes.
|
||||
_LASTUSED_THROTTLE_SECONDS = 60
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _extract_pat_secret():
|
||||
"""Return the PAT secret from the Authorization header, or None."""
|
||||
header = request.headers.get('Authorization', '')
|
||||
parts = header.split()
|
||||
if len(parts) == 2 and parts[0] == 'Bearer' \
|
||||
and parts[1].startswith(TOKEN_SECRET_PREFIX):
|
||||
return parts[1]
|
||||
return None
|
||||
|
||||
|
||||
def resolve_api_token(secret):
|
||||
"""Validate a PAT secret. Return (token, user) or None.
|
||||
|
||||
Shared validator: hash lookup, active token, unexpired, active owner. The
|
||||
before_request shim and the collector API (which does not decode JWT) both
|
||||
call this so the checks live in one place.
|
||||
"""
|
||||
from shopdb.core.models import User
|
||||
|
||||
token = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret), isactive=True).first()
|
||||
if token is None or token.is_expired:
|
||||
return None
|
||||
user = db.session.get(User, token.userid)
|
||||
if user is None or not user.isactive:
|
||||
return None
|
||||
return token, user
|
||||
|
||||
|
||||
def touch_apitoken_lastused(token):
|
||||
"""Throttled lastusedat write. Independent commit; nothing else is pending
|
||||
this early in the request, so it cannot clobber route work."""
|
||||
now = _utcnow()
|
||||
if token.lastusedat is None \
|
||||
or (now - token.lastusedat).total_seconds() > _LASTUSED_THROTTLE_SECONDS:
|
||||
token.lastusedat = now
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def service_token_authorized(scope):
|
||||
"""True when the current request carries a managed token scoped for `scope`
|
||||
whose owner is active and holds that permission. Accepts X-API-Key or a
|
||||
Bearer PAT (the before_request shim resolves Bearer into g.apitokenid).
|
||||
Touches lastusedat on success.
|
||||
|
||||
The single contract-surface entry point for unattended SERVICE tokens
|
||||
(collector.ingest, geenforce.fetch, ...), so plugins authorize a service
|
||||
token without reaching into core token internals. Returns False on any
|
||||
miss; the caller returns its own 401.
|
||||
"""
|
||||
from shopdb.core.models import User
|
||||
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
token = None
|
||||
if api_key and api_key.startswith(TOKEN_SECRET_PREFIX):
|
||||
resolved = resolve_api_token(api_key)
|
||||
token = resolved[0] if resolved else None
|
||||
else:
|
||||
tokenid = getattr(g, 'apitokenid', None)
|
||||
if tokenid is not None:
|
||||
token = db.session.get(ApiToken, tokenid)
|
||||
if token is None:
|
||||
return False
|
||||
scopelist = token.scopelist
|
||||
if not scopelist or scope not in scopelist:
|
||||
return False
|
||||
user = db.session.get(User, token.userid)
|
||||
if user is None or not user.isactive or not user.haspermission(scope):
|
||||
return False
|
||||
touch_apitoken_lastused(token)
|
||||
return True
|
||||
|
||||
|
||||
def install_apitoken_auth(app):
|
||||
"""Register the before_request PAT shim on the app."""
|
||||
|
||||
@app.before_request
|
||||
def _apitoken_before_request():
|
||||
secret = _extract_pat_secret()
|
||||
if secret is None:
|
||||
return
|
||||
resolved = resolve_api_token(secret)
|
||||
if resolved is None:
|
||||
# The caller clearly meant to use a PAT (shopdb_pat_ prefix) but it
|
||||
# is unknown, revoked, or expired. Reject with a clear 401 instead
|
||||
# of letting the JWT decoder emit a confusing 422 on the non-JWT.
|
||||
return error_response(
|
||||
ErrorCodes.UNAUTHORIZED,
|
||||
'Invalid, revoked, or expired API token',
|
||||
http_code=401)
|
||||
token, user = resolved
|
||||
|
||||
# Read claim inputs before the (possible) commit expires the instance.
|
||||
claims = {
|
||||
'username': user.username,
|
||||
'roles': [role.rolename for role in user.roles],
|
||||
}
|
||||
# A scoped token carries a patscopes claim; authz reads it to grant ONLY
|
||||
# the listed permissions and to deny role gates + import mode. Unscoped
|
||||
# tokens carry no such claim and mint exactly as a login JWT would.
|
||||
scopelist = token.scopelist
|
||||
if scopelist is not None:
|
||||
claims['patscopes'] = scopelist
|
||||
# Expose the token/user for audit and introspection if a handler wants it.
|
||||
g.apitokenid = token.tokenid
|
||||
g.apitokenuser = user
|
||||
|
||||
touch_apitoken_lastused(token)
|
||||
|
||||
# Mint a request-scoped JWT for the owner and swap it into the header
|
||||
# so the whole downstream auth stack authenticates as that user.
|
||||
access_token = create_access_token(
|
||||
identity=str(user.userid), additional_claims=claims)
|
||||
request.environ['HTTP_AUTHORIZATION'] = f'Bearer {access_token}'
|
||||
106
shopdb/utils/authz.py
Normal file
106
shopdb/utils/authz.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Authorization decorators for role and permission gating.
|
||||
|
||||
Authentication (is the caller logged in?) is handled by Flask-JWT-Extended's
|
||||
@jwt_required. Authorization (is the caller ALLOWED to do this?) is handled
|
||||
here. The two are separate concerns; a route needs both on any state-changing
|
||||
action.
|
||||
|
||||
These decorators call verify_jwt_in_request() themselves, so they work whether
|
||||
or not a separate @jwt_required is also present. The admin role bypasses every
|
||||
permission check (see User.haspermission), so an admin never needs individual
|
||||
permissions granted.
|
||||
|
||||
Usage:
|
||||
|
||||
@assets_bp.route('/<int:asset_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('assets.delete')
|
||||
def delete_asset(asset_id):
|
||||
...
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from flask_jwt_extended import verify_jwt_in_request, current_user, get_jwt
|
||||
|
||||
from shopdb.utils.responses import error_response, ErrorCodes
|
||||
|
||||
|
||||
def require_permission(permission_name: str):
|
||||
"""Gate a route behind a single permission. Admin role bypasses.
|
||||
|
||||
A scoped personal API token (patscopes claim present) does NOT bypass: it
|
||||
grants only the permissions in its scope list, intersected with what the
|
||||
owner actually holds. See shopdb/utils/apitoken_auth.py.
|
||||
"""
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# verify jwt first so current_user is loaded (idempotent if the
|
||||
# route also has @jwt_required)
|
||||
verify_jwt_in_request()
|
||||
if current_user is None:
|
||||
return error_response(
|
||||
ErrorCodes.UNAUTHORIZED,
|
||||
'Authentication required',
|
||||
http_code=401
|
||||
)
|
||||
patscopes = get_jwt().get('patscopes')
|
||||
if patscopes is not None:
|
||||
# Scoped PAT: allowed only when this permission is in the scope
|
||||
# list AND the owner actually holds it. haspermission still
|
||||
# returns True for an admin owner (who legitimately holds
|
||||
# everything), so the scope list is the real limiter - the
|
||||
# admin bypass is suspended.
|
||||
allowed = (permission_name in patscopes
|
||||
and current_user.haspermission(permission_name))
|
||||
if not allowed:
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'This API token is not scoped for this action',
|
||||
http_code=403
|
||||
)
|
||||
return view_func(*args, **kwargs)
|
||||
if not current_user.haspermission(permission_name):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to perform this action',
|
||||
http_code=403
|
||||
)
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def require_role(rolename: str):
|
||||
"""Gate a route behind a single role (e.g. 'admin').
|
||||
|
||||
A scoped personal API token (patscopes claim present) is ALWAYS denied here:
|
||||
scopes gate individual permissions, not roles, so role-gated admin surfaces
|
||||
require an unscoped token. See shopdb/utils/apitoken_auth.py.
|
||||
"""
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
verify_jwt_in_request()
|
||||
if current_user is None:
|
||||
return error_response(
|
||||
ErrorCodes.UNAUTHORIZED,
|
||||
'Authentication required',
|
||||
http_code=401
|
||||
)
|
||||
if get_jwt().get('patscopes') is not None:
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'Scoped API tokens cannot access role-gated endpoints',
|
||||
http_code=403
|
||||
)
|
||||
if not current_user.hasrole(rolename):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
f'{rolename.capitalize()} access required',
|
||||
http_code=403
|
||||
)
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
38
shopdb/utils/cmmc_usb_db.py
Normal file
38
shopdb/utils/cmmc_usb_db.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Connection helper for the CMMC USB check-in/out database.
|
||||
|
||||
Credentials are pulled from app config (env-backed, see Config.CMMC_USB_DB_*),
|
||||
never hardcoded. Used by the USB plugin to track device check-in/out against a
|
||||
separate MySQL database (cmmc_usb), mirroring the read-only employee-directory
|
||||
pattern in employee_db.py. Unlike the employee DB this one is read-write, so
|
||||
callers commit their own writes and close the connection in a finally block.
|
||||
"""
|
||||
|
||||
import pymysql
|
||||
from flask import current_app
|
||||
|
||||
|
||||
def _setting_or_config(setting_key, config_key):
|
||||
"""Non-secret config: a saved Setting wins, else the env-backed app config.
|
||||
|
||||
Lets the setup wizard edit host/name/user without touching .env, while the
|
||||
password stays env-only.
|
||||
"""
|
||||
from shopdb.core.models import Setting
|
||||
try:
|
||||
row = Setting.query.filter_by(key=setting_key).first()
|
||||
if row and row.value:
|
||||
return row.value
|
||||
except Exception:
|
||||
pass
|
||||
return current_app.config[config_key]
|
||||
|
||||
|
||||
def cmmc_usb_connection():
|
||||
"""Open a pymysql connection to the cmmc_usb DB."""
|
||||
return pymysql.connect(
|
||||
host=_setting_or_config('cmmc_usb_db_host', 'CMMC_USB_DB_HOST'),
|
||||
user=_setting_or_config('cmmc_usb_db_user', 'CMMC_USB_DB_USER'),
|
||||
password=current_app.config['CMMC_USB_DB_PASSWORD'],
|
||||
database=_setting_or_config('cmmc_usb_db_name', 'CMMC_USB_DB_NAME'),
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
)
|
||||
36
shopdb/utils/employee_db.py
Normal file
36
shopdb/utils/employee_db.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Connection helper for the read-only employee directory database.
|
||||
|
||||
Credentials are pulled from app config (env-backed, see Config.EMPLOYEE_DB_*),
|
||||
never hardcoded. Used by the employee lookup API and the notification
|
||||
recognition feature.
|
||||
"""
|
||||
|
||||
import pymysql
|
||||
from flask import current_app
|
||||
|
||||
|
||||
def _setting_or_config(setting_key, config_key):
|
||||
"""Non-secret config: a saved Setting wins, else the env-backed app config.
|
||||
|
||||
Lets the setup wizard edit host/name/user without touching .env, while the
|
||||
password stays env-only.
|
||||
"""
|
||||
from shopdb.core.models import Setting
|
||||
try:
|
||||
row = Setting.query.filter_by(key=setting_key).first()
|
||||
if row and row.value:
|
||||
return row.value
|
||||
except Exception:
|
||||
pass
|
||||
return current_app.config[config_key]
|
||||
|
||||
|
||||
def employee_connection():
|
||||
"""Open a pymysql connection to the employee directory DB."""
|
||||
return pymysql.connect(
|
||||
host=_setting_or_config('employee_db_host', 'EMPLOYEE_DB_HOST'),
|
||||
user=_setting_or_config('employee_db_user', 'EMPLOYEE_DB_USER'),
|
||||
password=current_app.config['EMPLOYEE_DB_PASSWORD'],
|
||||
database=_setting_or_config('employee_db_name', 'EMPLOYEE_DB_NAME'),
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
)
|
||||
111
shopdb/utils/import_mode.py
Normal file
111
shopdb/utils/import_mode.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Import-mode helpers: let admins replay legacy history through the API.
|
||||
|
||||
A migration script importing the classic ASP shopdb
|
||||
needs two things the normal API withholds:
|
||||
|
||||
1. Preserve each row's original createddate/modifieddate instead of stamping
|
||||
"now" on insert/update.
|
||||
2. Backdate event history such as USB checkouts to when they really happened.
|
||||
|
||||
Both are gated. The caller must BOTH be an admin AND send the request header
|
||||
`X-Import-Mode: true`. Outside import mode every helper here is a no-op, so
|
||||
wiring a call into a normal create/update path never changes behavior for
|
||||
regular users. See the IMPORT-API wiki page for the operator manual.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import request
|
||||
from flask_jwt_extended import verify_jwt_in_request, current_user, get_jwt
|
||||
|
||||
|
||||
# Request header a migration client sets to opt a request into import mode.
|
||||
IMPORT_MODE_HEADER = 'X-Import-Mode'
|
||||
|
||||
# strptime formats accepted for import timestamps, tried in order. Covers the
|
||||
# ISO 'T' form and the legacy MySQL 'space' form, with and without fractions,
|
||||
# plus a bare date.
|
||||
_IMPORT_DATETIME_FORMATS = (
|
||||
'%Y-%m-%dT%H:%M:%S.%f',
|
||||
'%Y-%m-%dT%H:%M:%S',
|
||||
'%Y-%m-%dT%H:%M',
|
||||
'%Y-%m-%d %H:%M:%S.%f',
|
||||
'%Y-%m-%d %H:%M:%S',
|
||||
'%Y-%m-%d %H:%M',
|
||||
'%Y-%m-%d',
|
||||
)
|
||||
|
||||
|
||||
def import_mode_active():
|
||||
"""True when the request is an admin-authenticated import request.
|
||||
|
||||
Needs header `X-Import-Mode: true` AND an admin caller. Safe to call from
|
||||
any request context: verify_jwt_in_request is optional here so a missing or
|
||||
bad token yields False instead of raising, and it is idempotent when the
|
||||
route already ran @jwt_required."""
|
||||
header = (request.headers.get(IMPORT_MODE_HEADER) or '').strip().lower()
|
||||
if header != 'true':
|
||||
return False
|
||||
verify_jwt_in_request(optional=True)
|
||||
user = current_user
|
||||
if user is None or not user.hasrole('admin'):
|
||||
return False
|
||||
# A scoped PAT never gets import mode: import mode is an admin-role
|
||||
# capability and a scoped token suspends the admin bypass. get_jwt is safe
|
||||
# here - an admin user means a valid JWT was decoded above.
|
||||
if get_jwt().get('patscopes') is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def parse_import_datetime(value):
|
||||
"""Parse an import timestamp into naive UTC, or None.
|
||||
|
||||
Accepts ISO '2020-01-05T12:00:00', legacy 'YYYY-MM-DD HH:MM:SS', a bare
|
||||
date, or an already-parsed datetime. A trailing 'Z' is treated as UTC.
|
||||
Timezone-aware input is converted to UTC then stripped to naive, matching
|
||||
the repo convention of storing naive-UTC in DB DateTime columns."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
parsed = value
|
||||
else:
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if text[-1] in ('Z', 'z'):
|
||||
text = text[:-1]
|
||||
parsed = None
|
||||
for fmt in _IMPORT_DATETIME_FORMATS:
|
||||
try:
|
||||
parsed = datetime.strptime(text, fmt)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if parsed is None:
|
||||
# last resort: let fromisoformat try (handles offsets like +00:00)
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is not None:
|
||||
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
|
||||
def apply_import_timestamps(instance, data):
|
||||
"""Stamp createddate/modifieddate on a row from the payload, in import mode.
|
||||
|
||||
Call this in a create/update path after building the instance and before
|
||||
commit. It is a no-op unless ALL hold: import mode is active (admin +
|
||||
header), the payload carries the field, and the model actually has the
|
||||
column. Explicitly setting modifieddate also suppresses the column's
|
||||
onupdate=now default on updates, so legacy history survives edits too."""
|
||||
if not data or not import_mode_active():
|
||||
return
|
||||
created = parse_import_datetime(data.get('createddate'))
|
||||
if created is not None and hasattr(instance, 'createddate'):
|
||||
instance.createddate = created
|
||||
modified = parse_import_datetime(data.get('modifieddate'))
|
||||
if modified is not None and hasattr(instance, 'modifieddate'):
|
||||
instance.modifieddate = modified
|
||||
259
shopdb/utils/mailer.py
Normal file
259
shopdb/utils/mailer.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Email sending service (stdlib smtplib/ssl/email only).
|
||||
|
||||
Reads SMTP configuration settings-first (via the cached settings map) with an
|
||||
environment-variable fallback when any SMTP_* env var is present. When email is
|
||||
disabled or the host is unset the sender is a graceful no-op that logs a warning
|
||||
and returns False, so an unconfigured site never crashes on a send attempt.
|
||||
|
||||
Public helpers:
|
||||
send_email(to, subject, html, text=None) -> bool
|
||||
try_send(to, subject, html, text=None) -> (bool, error_or_None)
|
||||
send_alert(subject, html, text=None) -> bool
|
||||
render_email(title, body_html, intro=None) -> (html, text)
|
||||
render_table_email(title, columns, rows, intro=None) -> (html, text)
|
||||
|
||||
The SMTP password is never logged.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formataddr
|
||||
|
||||
from flask import current_app, has_app_context
|
||||
|
||||
# Env var names that back each SMTP setting when settings are blank.
|
||||
_ENV_MAP = {
|
||||
'smtp_host': 'SMTP_HOST',
|
||||
'smtp_port': 'SMTP_PORT',
|
||||
'smtp_username': 'SMTP_USERNAME',
|
||||
'smtp_password': 'SMTP_PASSWORD',
|
||||
'smtp_from_address': 'SMTP_FROM_ADDRESS',
|
||||
'smtp_from_name': 'SMTP_FROM_NAME',
|
||||
'alert_recipients': 'SMTP_ALERT_RECIPIENTS',
|
||||
}
|
||||
|
||||
# Connect/send timeout in seconds. Keeps a wedged relay from hanging a request.
|
||||
_SMTP_TIMEOUT = 10
|
||||
|
||||
|
||||
def _log():
|
||||
"""App logger when in an app context, else a module logger."""
|
||||
if has_app_context():
|
||||
return current_app.logger
|
||||
import logging
|
||||
return logging.getLogger('shopdb.mailer')
|
||||
|
||||
|
||||
def _env_active():
|
||||
"""True when the deployment supplies SMTP_* env overrides."""
|
||||
return any(k.startswith('SMTP_') for k in os.environ)
|
||||
|
||||
|
||||
def get_smtp_config():
|
||||
"""Resolve the SMTP config settings-first with env fallback.
|
||||
|
||||
Returns a dict with typed fields. `enabled` is False when the site has not
|
||||
turned email on; callers should treat that as a no-op signal.
|
||||
"""
|
||||
settings = {}
|
||||
if has_app_context():
|
||||
# Local import avoids a circular import at module load.
|
||||
from shopdb.core.api.settings import get_cached_settings
|
||||
try:
|
||||
settings = get_cached_settings() or {}
|
||||
except Exception:
|
||||
settings = {}
|
||||
|
||||
env_active = _env_active()
|
||||
|
||||
def pick(key, default=''):
|
||||
val = settings.get(key)
|
||||
if (val is None or val == '') and env_active:
|
||||
val = os.environ.get(_ENV_MAP.get(key, ''), default)
|
||||
return default if val is None else val
|
||||
|
||||
enabled = bool(settings.get('smtp_enabled'))
|
||||
if not enabled and env_active:
|
||||
enabled = os.environ.get('SMTP_ENABLED', '').lower() in ('true', '1', 'yes')
|
||||
|
||||
use_tls = settings.get('smtp_use_tls')
|
||||
if use_tls is None:
|
||||
if env_active:
|
||||
use_tls = os.environ.get('SMTP_USE_TLS', 'true').lower() in ('true', '1', 'yes')
|
||||
else:
|
||||
use_tls = True
|
||||
|
||||
try:
|
||||
port = int(pick('smtp_port', 587) or 587)
|
||||
except (ValueError, TypeError):
|
||||
port = 587
|
||||
|
||||
return {
|
||||
'enabled': enabled,
|
||||
'host': pick('smtp_host'),
|
||||
'port': port,
|
||||
'username': pick('smtp_username'),
|
||||
'password': pick('smtp_password'),
|
||||
'use_tls': bool(use_tls),
|
||||
'from_address': pick('smtp_from_address'),
|
||||
'from_name': pick('smtp_from_name') or 'ShopDB',
|
||||
'alert_recipients': pick('alert_recipients'),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_recipients(to):
|
||||
"""Coerce a recipient spec (string, comma/semicolon list, or iterable) to a
|
||||
clean list of addresses."""
|
||||
if not to:
|
||||
return []
|
||||
if isinstance(to, str):
|
||||
parts = re.split(r'[,;]', to)
|
||||
else:
|
||||
parts = list(to)
|
||||
return [p.strip() for p in parts if p and p.strip()]
|
||||
|
||||
|
||||
def try_send(to, subject, html, text=None):
|
||||
"""Send an email. Returns (ok, error).
|
||||
|
||||
ok is False with error=None when email is not configured (a graceful
|
||||
no-op). ok is False with an error string when a real send failed. The SMTP
|
||||
password is never included in the error.
|
||||
"""
|
||||
config = get_smtp_config()
|
||||
recipients = _normalize_recipients(to)
|
||||
|
||||
if not config['enabled'] or not config['host']:
|
||||
_log().warning('Email not sent: SMTP is disabled or host is unset.')
|
||||
return False, None
|
||||
if not recipients:
|
||||
_log().warning('Email not sent: no recipients.')
|
||||
return False, 'No recipients specified'
|
||||
if not config['from_address']:
|
||||
_log().warning('Email not sent: from address is unset.')
|
||||
return False, 'From address is not configured'
|
||||
|
||||
message = MIMEMultipart('alternative')
|
||||
message['Subject'] = subject
|
||||
message['From'] = formataddr((config['from_name'], config['from_address']))
|
||||
message['To'] = ', '.join(recipients)
|
||||
# Plaintext first so alternative-aware clients prefer the HTML part.
|
||||
message.attach(MIMEText(text or _html_to_text(html), 'plain', 'utf-8'))
|
||||
message.attach(MIMEText(html, 'html', 'utf-8'))
|
||||
|
||||
try:
|
||||
context = ssl.create_default_context()
|
||||
if config['port'] == 465:
|
||||
server = smtplib.SMTP_SSL(
|
||||
config['host'], config['port'],
|
||||
timeout=_SMTP_TIMEOUT, context=context)
|
||||
else:
|
||||
server = smtplib.SMTP(
|
||||
config['host'], config['port'], timeout=_SMTP_TIMEOUT)
|
||||
with server:
|
||||
if config['port'] != 465 and config['use_tls']:
|
||||
server.starttls(context=context)
|
||||
if config['username']:
|
||||
server.login(config['username'], config['password'])
|
||||
server.sendmail(config['from_address'], recipients, message.as_string())
|
||||
_log().info('Email sent to %d recipient(s): %s', len(recipients), subject)
|
||||
return True, None
|
||||
except Exception as exception:
|
||||
# Never let the password reach the log or the caller.
|
||||
error = _scrub(str(exception), config['password'])
|
||||
_log().error('Email send failed: %s', error)
|
||||
return False, error
|
||||
|
||||
|
||||
def send_email(to, subject, html, text=None):
|
||||
"""Send an email. Returns True on success, False otherwise (no-op safe)."""
|
||||
ok, _error = try_send(to, subject, html, text=text)
|
||||
return ok
|
||||
|
||||
|
||||
def send_alert(subject, html, text=None):
|
||||
"""Send an alert to the site's configured alert_recipients. Returns False
|
||||
when email is off or no alert recipients are configured."""
|
||||
config = get_smtp_config()
|
||||
recipients = _normalize_recipients(config['alert_recipients'])
|
||||
if not recipients:
|
||||
_log().warning('Alert not sent: no alert_recipients configured.')
|
||||
return False
|
||||
return send_email(recipients, subject, html, text=text)
|
||||
|
||||
|
||||
def _scrub(value, secret):
|
||||
"""Remove a secret substring from a string (defensive log hygiene)."""
|
||||
if secret and secret in value:
|
||||
return value.replace(secret, '***')
|
||||
return value
|
||||
|
||||
|
||||
def _html_to_text(html):
|
||||
"""Very small HTML-to-text fallback for the plaintext alternative."""
|
||||
text = re.sub(r'(?i)<br\s*/?>', '\n', html)
|
||||
text = re.sub(r'(?i)</(p|tr|div|h[1-6]|li)>', '\n', text)
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _escape(value):
|
||||
"""HTML-escape a cell value."""
|
||||
return (str('' if value is None else value)
|
||||
.replace('&', '&').replace('<', '<').replace('>', '>'))
|
||||
|
||||
|
||||
def render_email(title, body_html, intro=None):
|
||||
"""Wrap body HTML in a simple branded shell. Returns (html, text)."""
|
||||
intro_html = f'<p style="margin:0 0 16px;color:#444;">{_escape(intro)}</p>' if intro else ''
|
||||
html = (
|
||||
'<div style="font-family:Arial,Helvetica,sans-serif;max-width:640px;'
|
||||
'margin:0 auto;color:#222;">'
|
||||
f'<h2 style="color:#1a1a1a;margin:0 0 12px;">{_escape(title)}</h2>'
|
||||
f'{intro_html}{body_html}'
|
||||
'<hr style="border:none;border-top:1px solid #ddd;margin:24px 0 12px;">'
|
||||
'<p style="font-size:12px;color:#888;margin:0;">Sent by ShopDB.</p>'
|
||||
'</div>'
|
||||
)
|
||||
return html, _html_to_text(html)
|
||||
|
||||
|
||||
def render_table_email(title, columns, rows, intro=None):
|
||||
"""Render tabular report data as an HTML table email. Returns (html, text).
|
||||
|
||||
columns: list of {'key','label'} dicts or of plain strings.
|
||||
rows: list of dicts keyed by the column keys.
|
||||
"""
|
||||
normalized = []
|
||||
for column in columns or []:
|
||||
if isinstance(column, dict):
|
||||
normalized.append((column.get('key'), column.get('label', column.get('key'))))
|
||||
else:
|
||||
normalized.append((column, column))
|
||||
|
||||
header_cells = ''.join(
|
||||
f'<th style="text-align:left;padding:8px 10px;border-bottom:2px solid #ccc;'
|
||||
f'background:#f4f4f4;">{_escape(label)}</th>'
|
||||
for _key, label in normalized)
|
||||
|
||||
body_rows = []
|
||||
for row in rows or []:
|
||||
cells = ''.join(
|
||||
f'<td style="padding:8px 10px;border-bottom:1px solid #eee;">'
|
||||
f'{_escape(row.get(key) if isinstance(row, dict) else row)}</td>'
|
||||
for key, _label in normalized)
|
||||
body_rows.append(f'<tr>{cells}</tr>')
|
||||
|
||||
table = (
|
||||
'<table style="border-collapse:collapse;width:100%;font-size:14px;">'
|
||||
f'<thead><tr>{header_cells}</tr></thead>'
|
||||
f'<tbody>{"".join(body_rows) or "<tr><td>No data</td></tr>"}</tbody>'
|
||||
'</table>'
|
||||
)
|
||||
count_line = f'<p style="color:#666;font-size:13px;">{len(rows or [])} row(s).</p>'
|
||||
return render_email(title, table + count_line, intro=intro)
|
||||
43
shopdb/utils/pagination.py
Normal file
43
shopdb/utils/pagination.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Pagination utilities."""
|
||||
|
||||
from flask import request, current_app
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
def get_pagination_params(request_obj=None) -> Tuple[int, int]:
|
||||
"""
|
||||
Extract pagination parameters from request.
|
||||
|
||||
Returns:
|
||||
Tuple of (page, per_page)
|
||||
"""
|
||||
if request_obj is None:
|
||||
request_obj = request
|
||||
|
||||
default_size = current_app.config.get('DEFAULT_PAGE_SIZE', 20)
|
||||
max_size = current_app.config.get('MAX_PAGE_SIZE', 100)
|
||||
|
||||
try:
|
||||
page = max(1, int(request_obj.args.get('page', 1)))
|
||||
except (TypeError, ValueError):
|
||||
page = 1
|
||||
|
||||
try:
|
||||
per_page = int(request_obj.args.get('perpage', request_obj.args.get('per_page', default_size)))
|
||||
per_page = max(1, min(per_page, max_size))
|
||||
except (TypeError, ValueError):
|
||||
per_page = default_size
|
||||
|
||||
return page, per_page
|
||||
|
||||
|
||||
def paginate_query(query, page: int, per_page: int):
|
||||
"""
|
||||
Apply pagination to a SQLAlchemy query.
|
||||
|
||||
Returns:
|
||||
Tuple of (items, total)
|
||||
"""
|
||||
total = query.count()
|
||||
items = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
return items, total
|
||||
175
shopdb/utils/responses.py
Normal file
175
shopdb/utils/responses.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Standardized API response helpers."""
|
||||
|
||||
from flask import jsonify, make_response
|
||||
from typing import Any, Dict, List
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
|
||||
class ErrorCodes:
|
||||
"""Standard error codes."""
|
||||
|
||||
VALIDATION_ERROR = 'VALIDATION_ERROR'
|
||||
NOT_FOUND = 'NOT_FOUND'
|
||||
UNAUTHORIZED = 'UNAUTHORIZED'
|
||||
FORBIDDEN = 'FORBIDDEN'
|
||||
CONFLICT = 'CONFLICT'
|
||||
INTERNAL_ERROR = 'INTERNAL_ERROR'
|
||||
BAD_REQUEST = 'BAD_REQUEST'
|
||||
PLUGIN_ERROR = 'PLUGIN_ERROR'
|
||||
|
||||
|
||||
def api_response(
|
||||
data: Any = None,
|
||||
message: str = None,
|
||||
status: str = 'success',
|
||||
meta: Dict = None,
|
||||
http_code: int = 200
|
||||
):
|
||||
"""
|
||||
Create standardized API response.
|
||||
|
||||
Response format:
|
||||
{
|
||||
"status": "success" | "error",
|
||||
"data": {...} | [...],
|
||||
"message": "Optional message",
|
||||
"meta": {
|
||||
"timestamp": "2025-01-12T...",
|
||||
"request_id": "uuid"
|
||||
}
|
||||
}
|
||||
"""
|
||||
response = {
|
||||
'status': status,
|
||||
'meta': {
|
||||
'timestamp': datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z',
|
||||
'requestid': str(uuid.uuid4())[:8],
|
||||
**(meta or {})
|
||||
}
|
||||
}
|
||||
|
||||
if data is not None:
|
||||
response['data'] = data
|
||||
|
||||
if message:
|
||||
response['message'] = message
|
||||
|
||||
return make_response(jsonify(response), http_code)
|
||||
|
||||
|
||||
def success_response(
|
||||
data: Any = None,
|
||||
message: str = None,
|
||||
meta: Dict = None,
|
||||
http_code: int = 200
|
||||
):
|
||||
"""Success response helper."""
|
||||
return api_response(
|
||||
data=data,
|
||||
message=message,
|
||||
meta=meta,
|
||||
status='success',
|
||||
http_code=http_code
|
||||
)
|
||||
|
||||
|
||||
def error_response(
|
||||
code: str,
|
||||
message: str,
|
||||
details: Dict = None,
|
||||
http_code: int = 400
|
||||
):
|
||||
"""Error response helper.
|
||||
|
||||
Response format:
|
||||
{
|
||||
"status": "error",
|
||||
"data": {
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Human-readable message",
|
||||
"details": {...}
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"timestamp": "...",
|
||||
"requestid": "..."
|
||||
}
|
||||
}
|
||||
"""
|
||||
error_data = {
|
||||
'code': code,
|
||||
'message': message
|
||||
}
|
||||
if details:
|
||||
error_data['details'] = details
|
||||
|
||||
return api_response(
|
||||
data={'error': error_data},
|
||||
status='error',
|
||||
http_code=http_code
|
||||
)
|
||||
|
||||
|
||||
def api_error(
|
||||
message: str,
|
||||
code: str = ErrorCodes.BAD_REQUEST,
|
||||
details: Dict = None,
|
||||
http_code: int = 400
|
||||
):
|
||||
"""
|
||||
Simplified error response helper.
|
||||
|
||||
Args:
|
||||
message: Human-readable error message
|
||||
code: Error code (default BAD_REQUEST)
|
||||
details: Optional error details
|
||||
http_code: HTTP status code (default 400)
|
||||
"""
|
||||
return error_response(code=code, message=message, details=details, http_code=http_code)
|
||||
|
||||
|
||||
def paginated_response(
|
||||
items: List,
|
||||
page: int,
|
||||
per_page: int,
|
||||
total: int,
|
||||
schema=None
|
||||
):
|
||||
"""Paginated list response.
|
||||
|
||||
Response format:
|
||||
{
|
||||
"status": "success",
|
||||
"data": [...],
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"perpage": 20,
|
||||
"total": 150,
|
||||
"totalpages": 8,
|
||||
"hasnext": true,
|
||||
"hasprev": false
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
total_pages = (total + per_page - 1) // per_page if per_page > 0 else 0
|
||||
|
||||
if schema:
|
||||
items = schema.dump(items, many=True)
|
||||
|
||||
return api_response(
|
||||
data=items,
|
||||
meta={
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'perpage': per_page,
|
||||
'total': total,
|
||||
'totalpages': total_pages,
|
||||
'hasnext': page < total_pages,
|
||||
'hasprev': page > 1
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user