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,7 +5,9 @@ from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog, Communication, CommunicationType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
|
||||
from ..models import Computer, ComputerType, ComputerInstalledApp
|
||||
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
|
||||
|
||||
from shopdb.api import require_permission, require_role
|
||||
|
||||
computers_bp = Blueprint('computers', __name__)
|
||||
|
||||
@@ -54,6 +56,7 @@ def get_computer_type(type_id: int):
|
||||
|
||||
@computers_bp.route('/types', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.create')
|
||||
def create_computer_type():
|
||||
"""Create a new computer type."""
|
||||
data = request.get_json()
|
||||
@@ -61,7 +64,16 @@ def create_computer_type():
|
||||
if not data or not data.get('computertype'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'computertype is required')
|
||||
|
||||
if ComputerType.query.filter_by(computertype=data['computertype']).first():
|
||||
existing = ComputerType.query.filter_by(computertype=data['computertype']).first()
|
||||
if existing:
|
||||
if not existing.isactive:
|
||||
# Adding a name that matches a deactivated type revives it.
|
||||
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 computer type')
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Computer type '{data['computertype']}' already exists",
|
||||
@@ -71,7 +83,7 @@ def create_computer_type():
|
||||
t = ComputerType(
|
||||
computertype=data['computertype'],
|
||||
description=data.get('description'),
|
||||
icon=data.get('icon')
|
||||
icon=data.get('icon'), color=data.get('color')
|
||||
)
|
||||
|
||||
db.session.add(t)
|
||||
@@ -82,6 +94,7 @@ def create_computer_type():
|
||||
|
||||
@computers_bp.route('/types/<int:type_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.edit')
|
||||
def update_computer_type(type_id: int):
|
||||
"""Update a computer type."""
|
||||
t = ComputerType.query.get(type_id)
|
||||
@@ -105,7 +118,7 @@ def update_computer_type(type_id: int):
|
||||
http_code=409
|
||||
)
|
||||
|
||||
for key in ['computertype', 'description', 'icon', 'isactive']:
|
||||
for key in ['computertype', 'description', 'icon', 'color', 'isactive']:
|
||||
if key in data:
|
||||
setattr(t, key, data[key])
|
||||
|
||||
@@ -113,6 +126,168 @@ def update_computer_type(type_id: int):
|
||||
return success_response(t.to_dict(), message='Computer type updated')
|
||||
|
||||
|
||||
@computers_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.delete')
|
||||
def delete_computer_type(type_id: int):
|
||||
"""Delete a computer type. Refused if any PC still uses it."""
|
||||
t = ComputerType.query.get(type_id)
|
||||
if not t:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Computer type not found', http_code=404)
|
||||
inuse = Computer.query.filter_by(computertypeid=type_id).count()
|
||||
if inuse:
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f"Cannot delete: {inuse} PC(s) still use this type", http_code=409)
|
||||
db.session.delete(t)
|
||||
db.session.commit()
|
||||
return success_response(message='Computer type deleted')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Access protocol catalog (VNC / WinRM / RDP / ...) - admin-managed
|
||||
# =============================================================================
|
||||
|
||||
@computers_bp.route('/protocols', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_protocols():
|
||||
"""List access protocols. ?active=false includes disabled ones."""
|
||||
query = AccessProtocol.query
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(AccessProtocol.isactive == True)
|
||||
protocols = query.order_by(AccessProtocol.name).all()
|
||||
return success_response([p.to_dict() for p in protocols])
|
||||
|
||||
|
||||
@computers_bp.route('/protocols', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.edit')
|
||||
def create_protocol():
|
||||
data = request.get_json() or {}
|
||||
if not (data.get('name') and data.get('scheme') and data.get('linktemplate')):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'name, scheme and linktemplate are required')
|
||||
if AccessProtocol.query.filter_by(name=data['name']).first():
|
||||
return error_response(ErrorCodes.CONFLICT, f"Protocol '{data['name']}' already exists", http_code=409)
|
||||
p = AccessProtocol(
|
||||
name=data['name'],
|
||||
scheme=data['scheme'],
|
||||
defaultport=data.get('defaultport') or None,
|
||||
linktemplate=data['linktemplate'],
|
||||
isactive=data.get('isactive', True),
|
||||
)
|
||||
db.session.add(p)
|
||||
db.session.commit()
|
||||
return success_response(p.to_dict(), message='Protocol created', http_code=201)
|
||||
|
||||
|
||||
@computers_bp.route('/protocols/<int:protocol_id>', methods=['PUT', 'PATCH'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.edit')
|
||||
def update_protocol(protocol_id):
|
||||
p = AccessProtocol.query.get(protocol_id)
|
||||
if not p:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Protocol not found', http_code=404)
|
||||
data = request.get_json() or {}
|
||||
for field in ('name', 'scheme', 'linktemplate'):
|
||||
if data.get(field):
|
||||
setattr(p, field, data[field])
|
||||
if 'defaultport' in data:
|
||||
p.defaultport = data['defaultport'] or None
|
||||
if 'isactive' in data:
|
||||
p.isactive = bool(data['isactive'])
|
||||
db.session.commit()
|
||||
return success_response(p.to_dict(), message='Protocol updated')
|
||||
|
||||
|
||||
@computers_bp.route('/protocols/<int:protocol_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.edit')
|
||||
def delete_protocol(protocol_id):
|
||||
p = AccessProtocol.query.get(protocol_id)
|
||||
if not p:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Protocol not found', http_code=404)
|
||||
# If any PC still references it, deactivate rather than hard-delete.
|
||||
if ComputerAccess.query.filter_by(protocolid=protocol_id).first():
|
||||
p.isactive = False
|
||||
db.session.commit()
|
||||
return success_response(message='Protocol is in use; deactivated instead of deleted')
|
||||
db.session.delete(p)
|
||||
db.session.commit()
|
||||
return success_response(message='Protocol deleted')
|
||||
|
||||
|
||||
def _computer_access_links(comp):
|
||||
"""Resolved remote-access links for a computer: each enabled protocol's
|
||||
template filled with the PC hostname joined to the pc_access_domain setting.
|
||||
A hostname that is already an FQDN (has a dot) is used as-is."""
|
||||
from shopdb.core.api.settings import get_cached_settings
|
||||
settings = get_cached_settings()
|
||||
domain = (settings.get('pc_access_domain') or '').strip()
|
||||
hostname = (comp.hostname or '').strip()
|
||||
if not hostname:
|
||||
host = ''
|
||||
elif '.' in hostname or not domain:
|
||||
host = hostname
|
||||
else:
|
||||
host = f"{hostname}.{domain}"
|
||||
|
||||
links = []
|
||||
for am in comp.accessmethods:
|
||||
protocol = am.protocol
|
||||
if not (am.isactive and protocol and protocol.isactive):
|
||||
continue
|
||||
port = am.portoverride or protocol.defaultport
|
||||
link = None
|
||||
if host:
|
||||
try:
|
||||
link = protocol.linktemplate.format(
|
||||
host=host,
|
||||
port=(port if port is not None else ''),
|
||||
scheme=protocol.scheme,
|
||||
)
|
||||
except (KeyError, IndexError, ValueError):
|
||||
link = None
|
||||
links.append({
|
||||
'id': am.id,
|
||||
'protocolid': protocol.protocolid,
|
||||
'name': protocol.name,
|
||||
'scheme': protocol.scheme,
|
||||
'port': port,
|
||||
'portoverride': am.portoverride,
|
||||
'link': link,
|
||||
})
|
||||
return links
|
||||
|
||||
|
||||
def _sync_access_methods(comp, data):
|
||||
"""Replace a computer's enabled protocols from data['accessmethods'] (a list
|
||||
of {protocolid, portoverride?}). No-op if the key is absent, so callers that
|
||||
don't touch access aren't affected."""
|
||||
if 'accessmethods' not in data:
|
||||
return
|
||||
desired = data.get('accessmethods') or []
|
||||
ComputerAccess.query.filter_by(computerid=comp.computerid).delete()
|
||||
seen = set()
|
||||
for m in desired:
|
||||
try:
|
||||
pid = int(m.get('protocolid'))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
port = m.get('portoverride')
|
||||
try:
|
||||
port = int(port) if port not in (None, '') else None
|
||||
except (TypeError, ValueError):
|
||||
port = None
|
||||
db.session.add(ComputerAccess(
|
||||
computerid=comp.computerid,
|
||||
protocolid=pid,
|
||||
portoverride=port,
|
||||
isactive=True,
|
||||
))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Computers CRUD
|
||||
# =============================================================================
|
||||
@@ -169,9 +344,15 @@ def list_computers():
|
||||
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
||||
|
||||
# Shopfloor filter
|
||||
# Shopfloor filter (by the Shopfloor computer type)
|
||||
if shopfloor := request.args.get('shopfloor'):
|
||||
query = query.filter(Computer.isshopfloor == (shopfloor.lower() == 'true'))
|
||||
sf = ComputerType.query.filter_by(computertype='Shopfloor').first()
|
||||
sf_id = sf.computertypeid if sf else -1
|
||||
if shopfloor.lower() == 'true':
|
||||
query = query.filter(Computer.computertypeid == sf_id)
|
||||
else:
|
||||
query = query.filter(db.or_(Computer.computertypeid != sf_id,
|
||||
Computer.computertypeid.is_(None)))
|
||||
|
||||
# Sorting
|
||||
sort_by = request.args.get('sort', 'hostname')
|
||||
@@ -197,6 +378,7 @@ def list_computers():
|
||||
for comp in items:
|
||||
item = comp.asset.to_dict() if comp.asset else {}
|
||||
item['computer'] = comp.to_dict()
|
||||
item['accessmethods'] = _computer_access_links(comp)
|
||||
data.append(item)
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
@@ -221,6 +403,7 @@ def get_computer(computer_id: int):
|
||||
c.to_dict() for c in
|
||||
Communication.query.filter_by(assetid=comp.assetid).all()
|
||||
]
|
||||
result['accessmethods'] = _computer_access_links(comp)
|
||||
|
||||
return success_response(result)
|
||||
|
||||
@@ -265,6 +448,7 @@ def get_computer_by_hostname(hostname: str):
|
||||
|
||||
@computers_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.create')
|
||||
def create_computer():
|
||||
"""
|
||||
Create new computer (creates both Asset and Computer records).
|
||||
@@ -275,7 +459,6 @@ def create_computer():
|
||||
Optional fields:
|
||||
- name, serialnumber, statusid, locationid, businessunitid
|
||||
- computertypeid, hostname, osid
|
||||
- isvnc, iswinrm, isshopfloor
|
||||
- mapx, mapy, notes
|
||||
"""
|
||||
data = request.get_json()
|
||||
@@ -341,10 +524,7 @@ def create_computer():
|
||||
modelnumberid=data.get('modelnumberid'),
|
||||
loggedinuser=data.get('loggedinuser'),
|
||||
lastreporteddate=data.get('lastreporteddate'),
|
||||
lastboottime=data.get('lastboottime'),
|
||||
isvnc=data.get('isvnc', False),
|
||||
iswinrm=data.get('iswinrm', False),
|
||||
isshopfloor=data.get('isshopfloor', False)
|
||||
lastboottime=data.get('lastboottime')
|
||||
)
|
||||
|
||||
db.session.add(comp)
|
||||
@@ -361,6 +541,9 @@ def create_computer():
|
||||
isprimary=True,
|
||||
))
|
||||
|
||||
# Remote-access protocols
|
||||
_sync_access_methods(comp, data)
|
||||
|
||||
# Audit log
|
||||
AuditLog.log('created', 'Computer', entityid=comp.computerid,
|
||||
entityname=data.get('hostname') or data['assetnumber'])
|
||||
@@ -369,12 +552,14 @@ def create_computer():
|
||||
|
||||
result = asset.to_dict()
|
||||
result['computer'] = comp.to_dict()
|
||||
result['accessmethods'] = _computer_access_links(comp)
|
||||
|
||||
return success_response(result, message='Computer created', http_code=201)
|
||||
|
||||
|
||||
@computers_bp.route('/<int:computer_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.edit')
|
||||
def update_computer(computer_id: int):
|
||||
"""Update computer (both Asset and Computer records)."""
|
||||
comp = Computer.query.get(computer_id)
|
||||
@@ -429,7 +614,7 @@ def update_computer(computer_id: int):
|
||||
# Update computer fields
|
||||
computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid',
|
||||
'modelnumberid', 'loggedinuser', 'lastreporteddate',
|
||||
'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor']
|
||||
'lastboottime']
|
||||
for key in computer_fields:
|
||||
if key in data:
|
||||
old_val = getattr(comp, key)
|
||||
@@ -455,6 +640,9 @@ def update_computer(computer_id: int):
|
||||
elif primary:
|
||||
primary.ipaddress = None
|
||||
|
||||
# Remote-access protocols
|
||||
_sync_access_methods(comp, data)
|
||||
|
||||
# Audit log if there were changes
|
||||
if changes:
|
||||
AuditLog.log('updated', 'Computer', entityid=comp.computerid,
|
||||
@@ -464,12 +652,14 @@ def update_computer(computer_id: int):
|
||||
|
||||
result = asset.to_dict()
|
||||
result['computer'] = comp.to_dict()
|
||||
result['accessmethods'] = _computer_access_links(comp)
|
||||
|
||||
return success_response(result, message='Computer updated')
|
||||
|
||||
|
||||
@computers_bp.route('/<int:computer_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.delete')
|
||||
def delete_computer(computer_id: int):
|
||||
"""Delete (soft delete) computer."""
|
||||
comp = Computer.query.get(computer_id)
|
||||
@@ -522,6 +712,7 @@ def get_installed_apps(computer_id: int):
|
||||
|
||||
@computers_bp.route('/<int:computer_id>/apps', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.create')
|
||||
def add_installed_app(computer_id: int):
|
||||
"""Add an installed application to a computer."""
|
||||
comp = Computer.query.get(computer_id)
|
||||
@@ -578,6 +769,7 @@ def add_installed_app(computer_id: int):
|
||||
|
||||
@computers_bp.route('/<int:computer_id>/apps/<int:app_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.delete')
|
||||
def remove_installed_app(computer_id: int, app_id: int):
|
||||
"""Remove an installed application from a computer."""
|
||||
installed = ComputerInstalledApp.query.filter_by(
|
||||
@@ -605,6 +797,7 @@ def remove_installed_app(computer_id: int, app_id: int):
|
||||
|
||||
@computers_bp.route('/<int:computer_id>/report', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.create')
|
||||
def report_status(computer_id: int):
|
||||
"""
|
||||
Report computer status (for agent-based reporting).
|
||||
@@ -671,9 +864,10 @@ def dashboard_summary():
|
||||
).all()
|
||||
|
||||
# Count shopfloor vs non-shopfloor
|
||||
sf = ComputerType.query.filter_by(computertype='Shopfloor').first()
|
||||
shopfloor_count = db.session.query(Computer).join(Asset).filter(
|
||||
Asset.isactive == True,
|
||||
Computer.isshopfloor == True
|
||||
Computer.computertypeid == (sf.computertypeid if sf else -1)
|
||||
).count()
|
||||
|
||||
return success_response({
|
||||
|
||||
Reference in New Issue
Block a user