Add custom fields + warranty plugin, rework settings into two-pane shell

Feature work from the 2026-07 session:

Settings IA
- Replace the flat 27-card settings hub with a persistent two-pane shell
  (SettingsLayout.vue): grouped, searchable left rail + content pane.
- Nest all settings/* routes under the shell via router post-processing;
  shared nav catalog in settingsNav.js. Group by asset class (PCs, Printers,
  Equipment, Network) so per-type settings stop scattering.

Custom fields (core)
- customfields + customfieldvalues tables (migration 7d14), CRUD API at
  /api/customfields, per-asset value get/save.
- Settings management page + reusable CustomFieldsSection (detail) and
  CustomFieldsInputs (form) wired into all four asset types.

Warranty (new plugin)
- plugins/warranty: warranties + warrantyassets (migration 7d15), derived
  coverage status, provider abstraction (manual now; Dell/Lenovo/HP stubs).
- API CRUD + per-asset panel + report buckets; WarrantyPanel on all four
  detail pages; Warranties management page; Warranty report + Reports card.
- Seed warranty.* permissions.

Printer drivers
- printerdrivers table (migration 7d13) linked to printer models; drivers now
  surface on the matching printer's detail page.

Other
- PCDetail rebalanced (Network + Status + Warranty + custom fields on the right).
- Rename PCs list "Features" column to "Remote Access"; fix badge hover underline.
- Drop equipment islocationonly field.
- Centralize asset-type label/route maps into utils/assetTypes.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-09 15:37:21 -04:00
parent 419f26107d
commit 78a0ee8d83
154 changed files with 9479 additions and 1098 deletions

View File

@@ -18,6 +18,7 @@ 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
__all__ = [
'auth_bp',
@@ -38,4 +39,5 @@ __all__ = [
'settings_bp',
'auditlogs_bp',
'users_bp',
'customfields_bp',
]

View File

@@ -53,6 +53,8 @@ def _require_computer_models():
return models, None
from shopdb.utils.authz import require_permission, require_role
applications_bp = Blueprint('applications', __name__)
@@ -140,6 +142,7 @@ def get_application(app_id: int):
@applications_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('applications.create')
def create_application():
"""Create a new application."""
data = request.get_json()
@@ -182,6 +185,7 @@ def create_application():
@applications_bp.route('/<int:app_id>', methods=['PUT'])
@jwt_required()
@require_permission('applications.edit')
def update_application(app_id: int):
"""Update an application."""
app = Application.query.get(app_id)
@@ -226,6 +230,7 @@ def update_application(app_id: int):
@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 = Application.query.get(app_id)
@@ -258,6 +263,7 @@ def list_versions(app_id: int):
@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 = Application.query.get(app_id)
@@ -348,6 +354,7 @@ def list_machine_applications(machine_id: int):
@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()
@@ -399,6 +406,7 @@ def install_application(machine_id: int):
@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()
@@ -423,6 +431,7 @@ def uninstall_application(machine_id: int, app_id: int):
@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()
@@ -468,6 +477,7 @@ def list_support_teams():
@applications_bp.route('/supportteams', methods=['POST'])
@jwt_required()
@require_permission('applications.create')
def create_support_team():
"""Create a new support team."""
data = request.get_json()
@@ -498,6 +508,7 @@ def list_app_owners():
@applications_bp.route('/appowners', methods=['POST'])
@jwt_required()
@require_permission('applications.create')
def create_app_owner():
"""Create a new application owner."""
data = request.get_json()

View File

@@ -14,6 +14,8 @@ from shopdb.utils.responses import (
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
assets_bp = Blueprint('assets', __name__)
@@ -58,6 +60,7 @@ def get_asset_type(type_id: int):
@assets_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset_type():
"""Create a new asset type."""
data = request.get_json()
@@ -77,7 +80,8 @@ def create_asset_type():
pluginname=data.get('pluginname'),
tablename=data.get('tablename'),
description=data.get('description'),
icon=data.get('icon')
icon=data.get('icon'),
color=data.get('color')
)
db.session.add(t)
@@ -86,6 +90,23 @@ def create_asset_type():
return success_response(t.to_dict(), message='Asset type created', http_code=201)
@assets_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_asset_type(type_id: int):
"""Update an asset type's display fields (color/icon/description). The name,
plugin, and table are structural and not editable here."""
t = AssetType.query.get(type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Asset type not found', http_code=404)
data = request.get_json() or {}
for key in ('description', 'icon', 'color', 'isactive'):
if key in data:
setattr(t, key, data[key])
db.session.commit()
return success_response(t.to_dict(), message='Asset type updated')
# =============================================================================
# Asset Statuses
# =============================================================================
@@ -127,6 +148,7 @@ def get_asset_status(status_id: int):
@assets_bp.route('/statuses', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset_status():
"""Create a new asset status."""
data = request.get_json()
@@ -155,6 +177,7 @@ def create_asset_status():
@assets_bp.route('/statuses/<int:status_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_asset_status(status_id: int):
"""Update an asset status."""
s = AssetStatus.query.get(status_id)
@@ -183,6 +206,7 @@ def update_asset_status(status_id: int):
@assets_bp.route('/statuses/<int:status_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_asset_status(status_id: int):
"""Delete an asset status. Refused if any asset still uses it."""
s = AssetStatus.query.get(status_id)
@@ -215,12 +239,14 @@ def list_relationship_types():
return success_response([{
'relationshiptypeid': t.relationshiptypeid,
'relationshiptype': t.relationshiptype,
'description': t.description
'description': t.description,
'color': t.color
} for t in types])
@assets_bp.route('/relationshiptypes', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_relationship_type():
"""Create a new asset relationship type."""
data = request.get_json()
@@ -236,16 +262,59 @@ def create_relationship_type():
rel_type = RelationshipType(
relationshiptype=data['relationshiptype'],
description=data.get('description')
description=data.get('description'),
color=data.get('color')
)
db.session.add(rel_type)
db.session.commit()
return success_response({
'relationshiptypeid': rel_type.relationshiptypeid,
'relationshiptype': rel_type.relationshiptype,
'description': rel_type.description
}, message='Relationship type created', http_code=201)
return success_response(_rel_type_dict(rel_type), message='Relationship type created', http_code=201)
def _rel_type_dict(t):
return {
'relationshiptypeid': t.relationshiptypeid,
'relationshiptype': t.relationshiptype,
'description': t.description,
'color': t.color,
}
@assets_bp.route('/relationshiptypes/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_relationship_type(type_id: int):
"""Update a relationship type."""
t = RelationshipType.query.get(type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Relationship type not found', http_code=404)
data = request.get_json() or {}
if 'relationshiptype' in data and data['relationshiptype'] != t.relationshiptype:
if RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first():
return error_response(ErrorCodes.CONFLICT,
f"Relationship type '{data['relationshiptype']}' already exists", http_code=409)
for key in ('relationshiptype', 'description', 'color'):
if key in data:
setattr(t, key, data[key])
db.session.commit()
return success_response(_rel_type_dict(t), message='Relationship type updated')
@assets_bp.route('/relationshiptypes/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_relationship_type(type_id: int):
"""Delete a relationship type. Refused if any relationship still uses it."""
t = RelationshipType.query.get(type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Relationship type not found', http_code=404)
inuse = AssetRelationship.query.filter_by(relationshiptypeid=type_id).count()
if inuse:
return error_response(ErrorCodes.CONFLICT,
f"Cannot delete: {inuse} relationship(s) still use this type", http_code=409)
db.session.delete(t)
db.session.commit()
return success_response(message='Relationship type deleted')
# =============================================================================
@@ -358,6 +427,7 @@ def get_asset(asset_id: int):
@assets_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset():
"""Create a new asset."""
data = request.get_json()
@@ -407,6 +477,7 @@ def create_asset():
@assets_bp.route('/<int:asset_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_asset(asset_id: int):
"""Update an asset."""
asset = Asset.query.get(asset_id)
@@ -447,6 +518,7 @@ def update_asset(asset_id: int):
@assets_bp.route('/<int:asset_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_asset(asset_id: int):
"""Delete (soft delete) an asset."""
asset = Asset.query.get(asset_id)
@@ -537,6 +609,7 @@ def get_asset_relationships(asset_id: int):
@assets_bp.route('/relationships', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset_relationship():
"""Create a relationship between two assets."""
data = request.get_json()
@@ -591,6 +664,7 @@ def create_asset_relationship():
@assets_bp.route('/relationships/<int:rel_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_asset_relationship(rel_id: int):
"""Delete an asset relationship."""
rel = AssetRelationship.query.get(rel_id)
@@ -837,7 +911,7 @@ def get_assets_map():
# Get filter options - these are small reference tables, no N+1 concern
asset_types = AssetType.query.filter(AssetType.isactive == True).all()
types_data = [{'assettypeid': t.assettypeid, 'assettype': t.assettype, 'icon': t.icon} for t in asset_types]
types_data = [{'assettypeid': t.assettypeid, 'assettype': t.assettype, 'icon': t.icon, 'color': t.color} for t in asset_types]
statuses = AssetStatus.query.filter(AssetStatus.isactive == True).all()
status_data = [{'statusid': s.statusid, 'status': s.status, 'color': s.color} for s in statuses]
@@ -854,28 +928,28 @@ def get_assets_map():
try:
from plugins.equipment.models import EquipmentType
equipment_types = EquipmentType.query.filter(EquipmentType.isactive == True).order_by(EquipmentType.equipmenttype).all()
subtypes['Equipment'] = [{'id': et.equipmenttypeid, 'name': et.equipmenttype} for et in equipment_types]
subtypes['Equipment'] = [{'id': et.equipmenttypeid, 'name': et.equipmenttype, 'color': et.color} for et in equipment_types]
except ImportError:
subtypes['Equipment'] = []
try:
from plugins.computers.models import ComputerType
computer_types = ComputerType.query.filter(ComputerType.isactive == True).order_by(ComputerType.computertype).all()
subtypes['Computer'] = [{'id': ct.computertypeid, 'name': ct.computertype} for ct in computer_types]
subtypes['Computer'] = [{'id': ct.computertypeid, 'name': ct.computertype, 'color': ct.color} for ct in computer_types]
except ImportError:
subtypes['Computer'] = []
try:
from plugins.network.models import NetworkDeviceType
net_types = NetworkDeviceType.query.filter(NetworkDeviceType.isactive == True).order_by(NetworkDeviceType.networkdevicetype).all()
subtypes['Network Device'] = [{'id': nt.networkdevicetypeid, 'name': nt.networkdevicetype} for nt in net_types]
subtypes['Network Device'] = [{'id': nt.networkdevicetypeid, 'name': nt.networkdevicetype, 'color': nt.color} for nt in net_types]
except ImportError:
subtypes['Network Device'] = []
try:
from plugins.printers.models import PrinterType
printer_types = PrinterType.query.filter(PrinterType.isactive == True).order_by(PrinterType.printertype).all()
subtypes['Printer'] = [{'id': pt.printertypeid, 'name': pt.printertype} for pt in printer_types]
subtypes['Printer'] = [{'id': pt.printertypeid, 'name': pt.printertype, 'color': pt.color} for pt in printer_types]
except ImportError:
subtypes['Printer'] = []

View File

@@ -1,5 +1,7 @@
"""Authentication API endpoints."""
from datetime import datetime, timedelta
from flask import Blueprint, request
from flask_jwt_extended import (
create_access_token,
@@ -16,6 +18,11 @@ 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
@auth_bp.route('/login', methods=['POST'])
def login():
@@ -50,20 +57,30 @@ def login():
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:
user.lockeduntil = datetime.utcnow() + timedelta(minutes=LOCKOUT_MINUTES)
user.failedlogins = 0
db.session.commit()
return error_response(
ErrorCodes.UNAUTHORIZED,
'Invalid username or password',
http_code=401
)
if user.islocked:
return error_response(
ErrorCodes.FORBIDDEN,
'Account is locked',
http_code=403
)
# Create tokens (identity must be a string in Flask-JWT-Extended 4.x)
access_token = create_access_token(
identity=str(user.userid),
@@ -74,9 +91,10 @@ def login():
)
refresh_token = create_refresh_token(identity=str(user.userid))
# Update last login
# 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({

View File

@@ -13,6 +13,8 @@ from shopdb.utils.responses import (
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
businessunits_bp = Blueprint('businessunits', __name__)
@@ -65,6 +67,7 @@ def get_businessunit(bu_id: int):
@businessunits_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_businessunit():
"""Create a new business unit."""
data = request.get_json()
@@ -94,6 +97,7 @@ def create_businessunit():
@businessunits_bp.route('/<int:bu_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_businessunit(bu_id: int):
"""Update a business unit."""
bu = BusinessUnit.query.get(bu_id)
@@ -127,6 +131,7 @@ def update_businessunit(bu_id: int):
@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 = BusinessUnit.query.get(bu_id)

View File

@@ -0,0 +1,216 @@
"""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 AssetType.query.get(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.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)),
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 = CustomField.query.get(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'):
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 = CustomField.query.get(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 = Asset.query.get(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 = Asset.query.get(assetid)
if not asset:
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
data = request.get_json() or {}
values = data.get('values') or {}
# Only accept fields that belong to this asset's type.
valid_ids = {f.fieldid for f in
CustomField.query.filter_by(assettypeid=asset.assettypeid).all()}
for raw_fieldid, raw_value in values.items():
try:
fieldid = int(raw_fieldid)
except (ValueError, TypeError):
continue
if fieldid not in valid_ids:
continue
value = '' if raw_value is None else str(raw_value)
row = CustomFieldValue.query.filter_by(fieldid=fieldid, assetid=assetid).first()
if value == '':
# Empty clears the stored value.
if row:
db.session.delete(row)
continue
if row:
row.value = value
else:
db.session.add(CustomFieldValue(fieldid=fieldid, assetid=assetid, value=value))
db.session.commit()
return success_response(_fields_with_values(asset), message='Custom fields saved')

View File

@@ -13,20 +13,90 @@ from shopdb.utils.responses import (
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
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 all location types."""
types = LocationType.query.filter_by(isactive=True).order_by(
LocationType.locationtype).all()
return success_response([{
'locationtypeid': t.locationtypeid,
'locationtype': t.locationtype,
'description': t.description,
} for t in 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 = LocationType.query.get(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 = LocationType.query.get(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'])
@@ -74,6 +144,7 @@ def get_location(location_id: int):
@locations_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_location():
"""Create a new location."""
data = request.get_json()
@@ -109,6 +180,7 @@ def create_location():
@locations_bp.route('/<int:location_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_location(location_id: int):
"""Update a location."""
loc = Location.query.get(location_id)
@@ -144,6 +216,7 @@ def update_location(location_id: int):
@locations_bp.route('/<int:location_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_location(location_id: int):
"""Delete (deactivate) a location."""
loc = Location.query.get(location_id)

View File

@@ -13,6 +13,8 @@ from shopdb.utils.responses import (
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
machinetypes_bp = Blueprint('machinetypes', __name__)
@@ -59,6 +61,7 @@ def get_machinetype(type_id: int):
@machinetypes_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_machinetype():
"""Create a new machine type."""
data = request.get_json()
@@ -88,6 +91,7 @@ def create_machinetype():
@machinetypes_bp.route('/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_machinetype(type_id: int):
"""Update a machine type."""
mt = MachineType.query.get(type_id)
@@ -122,6 +126,7 @@ def update_machinetype(type_id: int):
@machinetypes_bp.route('/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_machinetype(type_id: int):
"""Delete (deactivate) a machine type."""
mt = MachineType.query.get(type_id)

View File

@@ -13,6 +13,8 @@ from shopdb.utils.responses import (
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
models_bp = Blueprint('models', __name__)
@@ -72,6 +74,7 @@ def get_model(model_id: int):
@models_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_model():
"""Create a new model."""
data = request.get_json()
@@ -109,6 +112,7 @@ def create_model():
@models_bp.route('/<int:model_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_model(model_id: int):
"""Update a model."""
m = Model.query.get(model_id)
@@ -134,6 +138,7 @@ def update_model(model_id: int):
@models_bp.route('/<int:model_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_model(model_id: int):
"""Delete (deactivate) a model."""
m = Model.query.get(model_id)

View File

@@ -13,6 +13,8 @@ from shopdb.utils.responses import (
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
operatingsystems_bp = Blueprint('operatingsystems', __name__)
@@ -56,6 +58,7 @@ def get_operatingsystem(os_id: int):
@operatingsystems_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_operatingsystem():
"""Create a new operating system."""
data = request.get_json()
@@ -89,6 +92,7 @@ def create_operatingsystem():
@operatingsystems_bp.route('/<int:os_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_operatingsystem(os_id: int):
"""Update an operating system."""
os = OperatingSystem.query.get(os_id)
@@ -114,6 +118,7 @@ def update_operatingsystem(os_id: int):
@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 = OperatingSystem.query.get(os_id)

View File

@@ -5,6 +5,8 @@ from flask_jwt_extended import jwt_required
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_permission, require_role
plugins_bp = Blueprint('plugins', __name__)
@@ -26,6 +28,7 @@ def list_plugins():
@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."""

View File

@@ -7,6 +7,8 @@ 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__)
# Cache key for settings
@@ -100,6 +102,7 @@ def get_setting(key: str):
@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()
@@ -145,6 +148,7 @@ def update_setting(key: str):
@settings_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('settings.edit')
def create_setting():
"""Create a new setting (admin only)."""
data = request.get_json()
@@ -209,9 +213,72 @@ def build_default_settings():
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 the West
# Jefferson sitemap so an un-reconfigured install still renders.
mapdefaults = [
{
'key': 'map_blueprint_light',
'value': '/static/images/sitemap2025-light.png',
'valuetype': 'string',
'category': 'map',
'description': 'Floor-map blueprint image (light theme) for this facility'
},
{
'key': 'map_blueprint_dark',
'value': '/static/images/sitemap2025-dark.png',
'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': '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': 'West Jefferson',
'valuetype': 'string',
'category': 'site',
'description': 'Facility name shown on the shopfloor dashboard header'
},
{
'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.'
},
]
# Collector pc-type -> ComputerType mapping is computers-plugin domain;
# the plugin seeds pctypemap_<pxetype> settings on install.
defaults = identifierdefaults + searchdefaults + [
defaults = sitedefaults + identifierdefaults + searchdefaults + mapdefaults + [
# Zabbix integration
{
'key': 'zabbix_enabled',
@@ -363,6 +430,7 @@ def build_default_settings():
@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

View File

@@ -13,6 +13,8 @@ from shopdb.utils.responses import (
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
vendors_bp = Blueprint('vendors', __name__)
@@ -56,6 +58,7 @@ def get_vendor(vendor_id: int):
@vendors_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_vendor():
"""Create a new vendor."""
data = request.get_json()
@@ -87,6 +90,7 @@ def create_vendor():
@vendors_bp.route('/<int:vendor_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_vendor(vendor_id: int):
"""Update a vendor."""
v = Vendor.query.get(vendor_id)
@@ -120,6 +124,7 @@ def update_vendor(vendor_id: int):
@vendors_bp.route('/<int:vendor_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_vendor(vendor_id: int):
"""Delete (deactivate) a vendor."""
v = Vendor.query.get(vendor_id)