Files
shopdb-flask/plugins/warranty/api/routes.py
cproudlock 46e50c07ff
All checks were successful
CI / backend (push) Successful in 1m4s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Add the API import surface for legacy migrations (contract 0.8.0)
Goal: an LLM or script can migrate an entire legacy database using only
the HTTP API - original history preserved, safely re-runnable.

- X-Import-Mode header (admin only): create/update endpoints across 15
  timestamped entity types accept original createddate/modifieddate;
  helper exposed via shopdb.api (contract 0.7.0 -> 0.8.0).
- Exact-match natural-key lookup filters on 13 list endpoints for the
  lookup-then-upsert recipe.
- Selfhosted USB checkout/checkin accept backdated event times in
  import mode.
- docs/IMPORT-API.md: operator manual grounded in the real legacy
  schema - order of operations, full table-by-table mapping including
  the machines fan-out, idempotent Python importer with dry-run, parity
  checks, and decided dispositions for unmigrated tables (DNC config
  stays live-fed via the collector; supportteams/appowners map to the
  upcoming supportteams model).

635 tests pass; naming green; frontend untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:10:46 -04:00

326 lines
13 KiB
Python

"""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, timezone
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, WarrantyLookupError
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 = db.session.get(Asset, 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 db.session.get(Asset, 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)
# Exact-match natural-key lookup for idempotent import (servicetag + vendor).
if exactservicetag := request.args.get('servicetag'):
query = query.filter(Warranty.servicetag == exactservicetag)
if exactvendor := request.args.get('vendor'):
query = query.filter(Warranty.vendor == exactvendor)
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 = db.session.get(Warranty, 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 = db.session.get(Warranty, 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 = db.session.get(Warranty, 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 = db.session.get(Warranty, 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 = db.session.get(Warranty, 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, WarrantyLookupError) 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.now(timezone.utc).replace(tzinfo=None)
db.session.commit()
return success_response(_warranty_payload(warranty), message='Warranty refreshed')
# =============================================================================
# Bulk Dell sync - auto-detect Dell hardware by service tag and pull coverage
# =============================================================================
@warranty_bp.route('/sync/dell', methods=['POST'])
@jwt_required()
@require_permission('warranty.edit')
def sync_dell():
"""Look up every asset serial (= Dell service tag) that has no warranty yet
and create warranties for the ones Dell recognizes.
Only assets that lack a dated warranty are looked up, so re-running is cheap
and non-Dell serials are filtered out by Dell (they return no coverage).
"""
provider = get_provider('dell')
# ?all=true re-checks assets that already have a dated warranty too.
recheck_all = request.args.get('all', 'false').lower() == 'true'
# Assets already covered by a dated warranty - skipped unless recheck_all.
covered = set()
if not recheck_all:
for link in WarrantyAsset.query.all():
w = db.session.get(Warranty, link.warrantyid)
if w and w.isactive and w.enddate:
covered.add(link.assetid)
# Candidate assets: active, have a serial, not already covered.
candidates = (Asset.query
.filter(Asset.isactive.is_(True),
Asset.serialnumber.isnot(None),
Asset.serialnumber != '')
.all())
by_tag = {}
for asset in candidates:
if asset.assetid in covered:
continue
by_tag.setdefault(asset.serialnumber.strip().upper(), []).append(asset.assetid)
if not by_tag:
return success_response({
'candidates': 0, 'matched': 0, 'created': 0, 'updated': 0,
}, message='No PCs need a warranty lookup.')
try:
results = provider.bulk_lookup(list(by_tag.keys()))
except (ProviderNotConfigured, WarrantyLookupError) as exc:
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
created = updated = matched = 0
now = datetime.now(timezone.utc).replace(tzinfo=None)
for tag, assetids in by_tag.items():
found = results.get(tag)
if not found:
continue
matched += 1
for assetid in assetids:
# Reuse an existing Dell warranty for this asset if there is one.
existing = None
for link in WarrantyAsset.query.filter_by(assetid=assetid).all():
candidate = db.session.get(Warranty, link.warrantyid)
if candidate and candidate.provider == 'dell':
existing = candidate
break
if existing:
existing.servicelevel = found.get('servicelevel')
existing.startdate = _parse_date(found.get('startdate'))
existing.enddate = _parse_date(found.get('enddate'))
existing.servicetag = tag
existing.lastcheckeddate = now
updated += 1
else:
warranty = Warranty(
vendor='Dell', provider='dell', servicetag=tag,
servicelevel=found.get('servicelevel'),
startdate=_parse_date(found.get('startdate')),
enddate=_parse_date(found.get('enddate')),
lastcheckeddate=now,
)
warranty.links.append(WarrantyAsset(assetid=assetid))
db.session.add(warranty)
created += 1
db.session.commit()
return success_response({
'candidates': sum(len(v) for v in by_tag.values()),
'tags': len(by_tag),
'matched': matched,
'created': created,
'updated': updated,
}, message=f'Dell sync: {created} added, {updated} updated, {matched} tags matched.')
# =============================================================================
# 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,
})