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

@@ -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'] = []