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:
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'] = []
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
216
shopdb/core/api/customfields.py
Normal file
216
shopdb/core/api/customfields.py
Normal 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')
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user