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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user