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

@@ -5,9 +5,9 @@ import logging
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import db, cache, Asset, AssetType, Vendor, Model, Communication, CommunicationType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from shopdb.api import db, cache, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Printer, PrinterType, ModelSupply
from ..models import Printer, PrinterType, ModelSupply, PrinterDriver
from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS
from ..services import (
ZabbixService,
@@ -19,6 +19,8 @@ from ..services import (
logger = logging.getLogger(__name__)
from shopdb.api import require_permission, require_role
printers_asset_bp = Blueprint('printers_asset', __name__)
@@ -66,6 +68,7 @@ def get_printer_type(type_id: int):
@printers_asset_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('printers.create')
def create_printer_type():
"""Create a new printer type."""
data = request.get_json()
@@ -73,7 +76,15 @@ def create_printer_type():
if not data or not data.get('printertype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'printertype is required')
if PrinterType.query.filter_by(printertype=data['printertype']).first():
existing = PrinterType.query.filter_by(printertype=data['printertype']).first()
if existing:
if not existing.isactive:
existing.isactive = True
for key in ('description', 'icon', 'color'):
if data.get(key) is not None:
setattr(existing, key, data[key])
db.session.commit()
return success_response(existing.to_dict(), message='Reactivated existing type')
return error_response(
ErrorCodes.CONFLICT,
f"Printer type '{data['printertype']}' already exists",
@@ -83,7 +94,7 @@ def create_printer_type():
t = PrinterType(
printertype=data['printertype'],
description=data.get('description'),
icon=data.get('icon')
icon=data.get('icon'), color=data.get('color')
)
db.session.add(t)
@@ -92,6 +103,110 @@ def create_printer_type():
return success_response(t.to_dict(), message='Printer type created', http_code=201)
@printers_asset_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('printers.edit')
def update_printer_type(type_id: int):
"""Update a printer type."""
t = PrinterType.query.get(type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND,
f'Printer type with ID {type_id} not found', http_code=404)
data = request.get_json() or {}
if 'printertype' in data and data['printertype'] != t.printertype:
if PrinterType.query.filter_by(printertype=data['printertype']).first():
return error_response(ErrorCodes.CONFLICT,
f"Printer type '{data['printertype']}' already exists", http_code=409)
for key in ['printertype', 'description', 'icon', 'color', 'isactive']:
if key in data:
setattr(t, key, data[key])
db.session.commit()
return success_response(t.to_dict(), message='Printer type updated')
@printers_asset_bp.route('/types/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_permission('printers.delete')
def delete_printer_type(type_id: int):
"""Delete a printer type. Refused if any printer still uses it."""
t = PrinterType.query.get(type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Printer type not found', http_code=404)
inuse = Printer.query.filter_by(printertypeid=type_id).count()
if inuse:
return error_response(ErrorCodes.CONFLICT,
f"Cannot delete: {inuse} printer(s) still use this type", http_code=409)
db.session.delete(t)
db.session.commit()
return success_response(message='Printer type deleted')
# =============================================================================
# Printer Drivers (named SMB / HTTP links to driver packages)
# =============================================================================
@printers_asset_bp.route('/drivers', methods=['GET'])
@jwt_required(optional=True)
def list_drivers():
"""List printer drivers. ?active=false includes inactive ones."""
query = PrinterDriver.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter_by(isactive=True)
drivers = query.order_by(PrinterDriver.name).all()
return success_response([d.to_dict() for d in drivers])
@printers_asset_bp.route('/drivers', methods=['POST'])
@jwt_required()
@require_permission('printers.create')
def create_driver():
data = request.get_json() or {}
if not (data.get('name') and data.get('location')):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name and location are required')
d = PrinterDriver(
name=data['name'],
location=data['location'],
description=data.get('description'),
modelnumberid=data.get('modelnumberid') or None,
isactive=data.get('isactive', True),
)
db.session.add(d)
db.session.commit()
return success_response(d.to_dict(), message='Driver created', http_code=201)
@printers_asset_bp.route('/drivers/<int:driver_id>', methods=['PUT'])
@jwt_required()
@require_permission('printers.edit')
def update_driver(driver_id):
d = PrinterDriver.query.get(driver_id)
if not d:
return error_response(ErrorCodes.NOT_FOUND, 'Driver not found', http_code=404)
data = request.get_json() or {}
for key in ('name', 'location', 'description', 'isactive'):
if key in data:
setattr(d, key, data[key])
if 'modelnumberid' in data:
d.modelnumberid = data['modelnumberid'] or None
db.session.commit()
return success_response(d.to_dict(), message='Driver updated')
@printers_asset_bp.route('/drivers/<int:driver_id>', methods=['DELETE'])
@jwt_required()
@require_permission('printers.delete')
def delete_driver(driver_id):
d = PrinterDriver.query.get(driver_id)
if not d:
return error_response(ErrorCodes.NOT_FOUND, 'Driver not found', http_code=404)
db.session.delete(d)
db.session.commit()
return success_response(message='Driver deleted')
# =============================================================================
# Printers CRUD
# =============================================================================
@@ -235,6 +350,50 @@ def printer_install_list():
return success_response(rows)
@printers_asset_bp.route('/pc-default', methods=['GET'])
@jwt_required(optional=True)
def pc_default_printer():
"""Default printer for a PC, by machine (asset) number.
Parity with classic apipcdefaultprinter.asp: the signed installer EXE
preselects a PC's default-printer hotspot on the site-map wizard using the
machine number persisted at PXE enrollment. The link is a `defaultprinter`
asset relationship (PC asset -> printer asset), so this stays inside the
contract surface (no cross-plugin model import).
Returns {printerid, windowsname}, or {} when the machine is unknown or has
no active default printer set.
"""
machine = (request.args.get('machine') or '').strip()
if not machine:
return success_response({})
pc = Asset.query.filter_by(assetnumber=machine, isactive=True).first()
dp_type = RelationshipType.query.filter_by(relationshiptype='defaultprinter').first()
if not pc or not dp_type:
return success_response({})
rel = AssetRelationship.query.filter_by(
sourceassetid=pc.assetid,
relationshiptypeid=dp_type.relationshiptypeid,
isactive=True,
).first()
if not rel:
return success_response({})
printer = db.session.query(Printer).join(Asset).filter(
Printer.assetid == rel.targetassetid,
Asset.isactive == True,
).first()
if not printer:
return success_response({})
return success_response({
'printerid': printer.printerid,
'windowsname': printer.windowsname,
})
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
@jwt_required(optional=True)
def get_printer(printer_id: int):
@@ -256,6 +415,15 @@ def get_printer(printer_id: int):
comms = Communication.query.filter_by(assetid=printer.asset.assetid).all()
result['communications'] = [c.to_dict() for c in comms]
# Attach active drivers that match this printer's model
if printer.modelnumberid:
drivers = PrinterDriver.query.filter_by(
modelnumberid=printer.modelnumberid, isactive=True
).order_by(PrinterDriver.name).all()
result['drivers'] = [d.to_dict() for d in drivers]
else:
result['drivers'] = []
return success_response(result)
@@ -280,6 +448,7 @@ def get_printer_by_asset(asset_id: int):
@printers_asset_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('printers.create')
def create_printer():
"""
Create new printer (creates both Asset and Printer records).
@@ -379,6 +548,7 @@ def create_printer():
@printers_asset_bp.route('/<int:printer_id>', methods=['PUT'])
@jwt_required()
@require_permission('printers.edit')
def update_printer(printer_id: int):
"""Update printer (both Asset and Printer records)."""
printer = Printer.query.get(printer_id)
@@ -453,6 +623,7 @@ def update_printer(printer_id: int):
@printers_asset_bp.route('/<int:printer_id>', methods=['DELETE'])
@jwt_required()
@require_permission('printers.delete')
def delete_printer(printer_id: int):
"""Delete (soft delete) printer."""
printer = Printer.query.get(printer_id)
@@ -697,6 +868,7 @@ def printer_lookup():
@printers_asset_bp.route('/supplies/refresh', methods=['POST'])
@jwt_required()
@require_permission('printers.create')
def refresh_supplies_cache():
"""Clear cached Zabbix supply data so the next read pulls fresh values.
@@ -878,6 +1050,7 @@ def list_model_supplies(modelnumberid: int):
@printers_asset_bp.route('/models/<int:modelnumberid>/supplies', methods=['POST'])
@jwt_required()
@require_permission('printers.create')
def create_model_supply(modelnumberid: int):
"""Add a supply to a model."""
model = Model.query.get(modelnumberid)
@@ -918,6 +1091,7 @@ def create_model_supply(modelnumberid: int):
@printers_asset_bp.route('/supplies/<int:modelsupplyid>', methods=['PUT'])
@jwt_required()
@require_permission('printers.edit')
def update_model_supply(modelsupplyid: int):
"""Update a model supply."""
supply = ModelSupply.query.get(modelsupplyid)
@@ -962,6 +1136,7 @@ def update_model_supply(modelsupplyid: int):
@printers_asset_bp.route('/supplies/<int:modelsupplyid>', methods=['DELETE'])
@jwt_required()
@require_permission('printers.delete')
def delete_model_supply(modelsupplyid: int):
"""Delete a model supply."""
supply = ModelSupply.query.get(modelsupplyid)