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

@@ -0,0 +1,5 @@
"""Warranty plugin package."""
from .plugin import WarrantyPlugin
__all__ = ['WarrantyPlugin']

View File

@@ -0,0 +1,5 @@
"""Warranty plugin API."""
from .routes import warranty_bp
__all__ = ['warranty_bp']

View File

@@ -0,0 +1,228 @@
"""Warranty API: manual CRUD now, provider refresh stubbed for later phases.
Coverage status is derived from enddate at read time (see models.derive_status),
never stored. Warranties link to assets many-to-many via warrantyassets, though
the common case is one warranty per asset.
"""
from datetime import date, datetime
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import (
db, Asset,
success_response, error_response, ErrorCodes,
require_permission,
)
from ..models import Warranty, WarrantyAsset
from ..services import get_provider, ProviderNotConfigured
warranty_bp = Blueprint('warranty', __name__)
def _parse_date(value):
"""Accept 'YYYY-MM-DD' (or None/empty) -> date or None."""
if not value:
return None
try:
return datetime.strptime(value[:10], '%Y-%m-%d').date()
except (ValueError, TypeError):
return None
def _asset_summary(asset):
return {
'assetid': asset.assetid,
'assetnumber': asset.assetnumber,
'name': asset.name,
'assettypename': asset.assettype.assettype if asset.assettype else None,
}
def _warranty_payload(warranty, today=None):
"""to_dict plus the linked-asset summaries."""
data = warranty.to_dict(today)
assets = []
for link in warranty.links:
asset = Asset.query.get(link.assetid)
if asset:
assets.append(_asset_summary(asset))
data['assets'] = assets
return data
def _apply_links(warranty, assetids):
"""Replace a warranty's asset links with the given asset id list."""
if assetids is None:
return
wanted = {int(a) for a in assetids if str(a).strip()}
existing = {link.assetid: link for link in warranty.links}
for assetid in wanted - set(existing):
if Asset.query.get(assetid):
warranty.links.append(WarrantyAsset(assetid=assetid))
for assetid in set(existing) - wanted:
warranty.links.remove(existing[assetid])
# =============================================================================
# CRUD
# =============================================================================
@warranty_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_warranties():
"""List warranties. Filters: ?status=, ?assetid=, ?active=false."""
query = Warranty.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter_by(isactive=True)
assetid = request.args.get('assetid', type=int)
if assetid:
query = (query.join(WarrantyAsset, WarrantyAsset.warrantyid == Warranty.warrantyid)
.filter(WarrantyAsset.assetid == assetid))
warranties = query.order_by(Warranty.enddate.is_(None), Warranty.enddate).all()
today = date.today()
items = [_warranty_payload(w, today) for w in warranties]
status_filter = request.args.get('status')
if status_filter:
items = [i for i in items if i['status'] == status_filter]
return success_response(items)
@warranty_bp.route('/asset/<int:assetid>', methods=['GET'])
@jwt_required(optional=True)
def warranties_for_asset(assetid):
"""Warranties covering one asset (for the asset-detail panel)."""
links = WarrantyAsset.query.filter_by(assetid=assetid).all()
today = date.today()
items = []
for link in links:
w = Warranty.query.get(link.warrantyid)
if w and w.isactive:
items.append(_warranty_payload(w, today))
return success_response(items)
@warranty_bp.route('/<int:warrantyid>', methods=['GET'])
@jwt_required(optional=True)
def get_warranty(warrantyid):
warranty = Warranty.query.get(warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
return success_response(_warranty_payload(warranty))
@warranty_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('warranty.create')
def create_warranty():
data = request.get_json() or {}
vendor = (data.get('vendor') or '').strip()
if not vendor:
return error_response(ErrorCodes.VALIDATION_ERROR, 'vendor is required')
warranty = Warranty(
vendor=vendor,
servicetag=(data.get('servicetag') or '').strip() or None,
provider=(data.get('provider') or 'manual').strip().lower(),
servicelevel=(data.get('servicelevel') or '').strip() or None,
startdate=_parse_date(data.get('startdate')),
enddate=_parse_date(data.get('enddate')),
notes=(data.get('notes') or '').strip() or None,
)
_apply_links(warranty, data.get('assetids'))
db.session.add(warranty)
db.session.commit()
return success_response(_warranty_payload(warranty), message='Warranty created', http_code=201)
@warranty_bp.route('/<int:warrantyid>', methods=['PUT'])
@jwt_required()
@require_permission('warranty.edit')
def update_warranty(warrantyid):
warranty = Warranty.query.get(warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
data = request.get_json() or {}
if 'vendor' in data:
warranty.vendor = (data['vendor'] or '').strip() or warranty.vendor
if 'servicetag' in data:
warranty.servicetag = (data['servicetag'] or '').strip() or None
if 'provider' in data:
warranty.provider = (data['provider'] or 'manual').strip().lower()
if 'servicelevel' in data:
warranty.servicelevel = (data['servicelevel'] or '').strip() or None
if 'startdate' in data:
warranty.startdate = _parse_date(data['startdate'])
if 'enddate' in data:
warranty.enddate = _parse_date(data['enddate'])
if 'notes' in data:
warranty.notes = (data['notes'] or '').strip() or None
if 'isactive' in data:
warranty.isactive = bool(data['isactive'])
if 'assetids' in data:
_apply_links(warranty, data['assetids'])
db.session.commit()
return success_response(_warranty_payload(warranty), message='Warranty updated')
@warranty_bp.route('/<int:warrantyid>', methods=['DELETE'])
@jwt_required()
@require_permission('warranty.delete')
def delete_warranty(warrantyid):
warranty = Warranty.query.get(warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
db.session.delete(warranty)
db.session.commit()
return success_response(message='Warranty deleted')
# =============================================================================
# Provider refresh (phase 1: manual only; API providers report not-configured)
# =============================================================================
@warranty_bp.route('/<int:warrantyid>/refresh', methods=['POST'])
@jwt_required()
@require_permission('warranty.edit')
def refresh_warranty(warrantyid):
warranty = Warranty.query.get(warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
provider = get_provider(warranty.provider)
try:
result = provider.lookup(warranty.servicetag, warranty.vendor)
except ProviderNotConfigured as exc:
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
if not result:
return error_response(ErrorCodes.VALIDATION_ERROR,
'This warranty is manual - nothing to refresh.', http_code=400)
if result.get('servicelevel'):
warranty.servicelevel = result['servicelevel']
if result.get('startdate'):
warranty.startdate = _parse_date(result['startdate'])
if result.get('enddate'):
warranty.enddate = _parse_date(result['enddate'])
warranty.lastcheckeddate = datetime.utcnow()
db.session.commit()
return success_response(_warranty_payload(warranty), message='Warranty refreshed')
# =============================================================================
# Report buckets (for the Reports hub)
# =============================================================================
@warranty_bp.route('/report', methods=['GET'])
@jwt_required(optional=True)
def warranty_report():
"""Counts + lists bucketed by derived status."""
today = date.today()
buckets = {'expired': [], 'expiring': [], 'active': [], 'unknown': []}
for w in Warranty.query.filter_by(isactive=True).all():
buckets.setdefault(w.status(today), []).append(_warranty_payload(w, today))
return success_response({
'counts': {k: len(v) for k, v in buckets.items()},
'buckets': buckets,
})

View File

@@ -0,0 +1,12 @@
{
"name": "warranty",
"version": "1.0.0",
"description": "Asset warranty tracking - manual entry now, Dell/Lenovo/HP provider lookups later. Derived coverage status + report buckets.",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.2.0,<1.0.0",
"api_prefix": "/api/warranty",
"provides": {
"features": ["warranty-tracking"]
}
}

View File

@@ -0,0 +1,5 @@
"""Warranty plugin models."""
from .warranty import Warranty, WarrantyAsset, derive_status, STATUS_COLORS
__all__ = ['Warranty', 'WarrantyAsset', 'derive_status', 'STATUS_COLORS']

View File

@@ -0,0 +1,96 @@
"""Warranty models.
A Warranty is provider-agnostic (manual entry, or looked up from Dell/Lenovo/HP
later). It links to one or more assets via warrantyassets. Coverage status is
DERIVED from enddate at read time, never stored, so it is always current.
"""
from datetime import date, timedelta
from shopdb.api import db
# Window before enddate where a warranty counts as "expiring soon".
EXPIRING_WINDOW_DAYS = 180
# Derived status -> display color (hex). Reused by the frontend status badge.
STATUS_COLORS = {
'active': '#4CAF50',
'expiring': '#FF9800',
'expired': '#F44336',
'unknown': '#9E9E9E',
}
def derive_status(enddate, today=None):
"""Coverage status from an end date. Never stored - always computed."""
if not enddate:
return 'unknown'
today = today or date.today()
if enddate < today:
return 'expired'
if enddate <= today + timedelta(days=EXPIRING_WINDOW_DAYS):
return 'expiring'
return 'active'
class Warranty(db.Model):
__tablename__ = 'warranties'
warrantyid = db.Column(db.Integer, primary_key=True)
vendor = db.Column(db.String(100), nullable=False)
# Service tag / serial the provider identifies the unit by.
servicetag = db.Column(db.String(100))
# Where the record came from: manual, dell, lenovo, hp.
provider = db.Column(db.String(20), nullable=False, server_default='manual')
servicelevel = db.Column(db.String(150))
startdate = db.Column(db.Date)
enddate = db.Column(db.Date)
# When a provider lookup last refreshed this record.
lastcheckeddate = db.Column(db.DateTime)
notes = db.Column(db.Text)
isactive = db.Column(db.Boolean, nullable=False, server_default='1')
links = db.relationship('WarrantyAsset', back_populates='warranty',
cascade='all, delete-orphan')
def status(self, today=None):
return derive_status(self.enddate, today)
def to_dict(self, today=None):
status = self.status(today)
return {
'warrantyid': self.warrantyid,
'vendor': self.vendor,
'servicetag': self.servicetag,
'provider': self.provider,
'servicelevel': self.servicelevel,
'startdate': self.startdate.isoformat() if self.startdate else None,
'enddate': self.enddate.isoformat() if self.enddate else None,
'lastcheckeddate': self.lastcheckeddate.isoformat() + 'Z' if self.lastcheckeddate else None,
'notes': self.notes,
'isactive': bool(self.isactive),
'status': status,
'statuscolor': STATUS_COLORS.get(status, STATUS_COLORS['unknown']),
}
class WarrantyAsset(db.Model):
__tablename__ = 'warrantyassets'
id = db.Column(db.Integer, primary_key=True)
warrantyid = db.Column(
db.Integer,
db.ForeignKey('warranties.warrantyid', ondelete='CASCADE'),
nullable=False
)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False
)
warranty = db.relationship('Warranty', back_populates='links')
__table_args__ = (
db.UniqueConstraint('warrantyid', 'assetid', name='uq_warrantyasset_warranty_asset'),
)

View File

@@ -0,0 +1,65 @@
"""Warranty plugin main class.
Asset-general plugin: owns its warranties + warrantyassets tables, an API
surface, and a sidebar entry. Not tied to any one asset type - a warranty can
cover a PC, printer, network device, or equipment.
"""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from .api import warranty_bp
from .models import Warranty, WarrantyAsset
logger = logging.getLogger(__name__)
class WarrantyPlugin(BasePlugin):
"""Warranty tracking plugin."""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists():
with open(manifest_path, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
return PluginMeta(
name=self._manifest.get('name', 'warranty'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get('description', 'Asset warranty tracking'),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=0.2.0,<1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/warranty'),
)
def get_blueprint(self) -> Optional[Blueprint]:
return warranty_bp
def get_models(self) -> List[Type]:
return [Warranty, WarrantyAsset]
def get_navigation_items(self) -> List[Dict]:
return [
{
'name': 'Warranties',
'icon': 'shield',
'route': '/warranties',
'position': 8,
},
]
def init_app(self, app: Flask, db_instance) -> None:
logger.info(f"Warranty plugin initialized (v{self.meta.version})")

View File

@@ -0,0 +1,9 @@
"""Warranty plugin services."""
from .providers import (
get_provider,
provider_names,
ProviderNotConfigured,
)
__all__ = ['get_provider', 'provider_names', 'ProviderNotConfigured']

View File

@@ -0,0 +1,93 @@
"""Warranty provider abstraction.
A provider looks up coverage for a unit by service tag / serial. Phase 1 ships
manual entry plus provider stubs that read per-vendor API config from settings.
When a real API (Dell TechDirect, Lenovo, HP) is wired later, only the matching
provider's lookup() body changes - callers and the API surface stay the same.
"""
from shopdb.api import db
from shopdb.core.models import Setting
class ProviderNotConfigured(Exception):
"""Raised when a provider is asked to look up but has no API config."""
pass
def _setting(key, default=None):
row = Setting.query.filter_by(key=key).first()
return row.value if row and row.value not in (None, '') else default
class WarrantyProvider:
"""Base provider. name matches Warranty.provider values."""
name = 'base'
def lookup(self, servicetag, vendor=None):
"""Return a dict of {servicelevel, startdate, enddate} or None.
Raises ProviderNotConfigured when the provider needs API creds it does
not have.
"""
raise NotImplementedError
class ManualProvider(WarrantyProvider):
"""No external lookup - values are entered by hand."""
name = 'manual'
def lookup(self, servicetag, vendor=None):
return None
class ApiProvider(WarrantyProvider):
"""Common shape for vendor API providers. Reads enabled/url/token from
settings under warranty_<name>_*. Real HTTP call is deferred to a later
phase; today it fails loud if asked to look up so manual entry is unaffected.
"""
def _config(self):
enabled = str(_setting(f'warranty_{self.name}_enabled', 'false')).lower() == 'true'
url = _setting(f'warranty_{self.name}_apiurl')
token = _setting(f'warranty_{self.name}_apitoken')
return enabled, url, token
def lookup(self, servicetag, vendor=None):
enabled, url, token = self._config()
if not (enabled and url and token):
raise ProviderNotConfigured(
f'{self.name} warranty lookup is not configured. '
f'Set warranty_{self.name}_enabled/apiurl/apitoken in Settings.'
)
# Phase 2+: perform the vendor API call here and map the response to
# {servicelevel, startdate, enddate}. Until then, signal not-yet-built.
raise ProviderNotConfigured(
f'{self.name} API lookup not implemented yet (config present).'
)
class DellProvider(ApiProvider):
name = 'dell'
class LenovoProvider(ApiProvider):
name = 'lenovo'
class HpProvider(ApiProvider):
name = 'hp'
_PROVIDERS = {p.name: p for p in (
ManualProvider(), DellProvider(), LenovoProvider(), HpProvider()
)}
def get_provider(name):
"""Return a provider instance, defaulting to manual for unknown names."""
return _PROVIDERS.get((name or 'manual').lower(), _PROVIDERS['manual'])
def provider_names():
return list(_PROVIDERS.keys())