The bulk /sync/dell reuse check only matched an existing warranty when its provider was exactly 'dell'. Warranties added by hand or via import default to provider 'manual', so re-check-all did not recognize them and created a brand new Dell warranty for every asset - duplicating the whole set. Broaden the reuse match to treat a warranty as Dell by any signal (provider, matching service tag, or a 'Dell' vendor), and canonicalize the reused row to provider 'dell' so later re-checks match by provider and never duplicate.
349 lines
14 KiB
Python
349 lines
14 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 sqlalchemy.orm import joinedload
|
|
|
|
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, assetmap=None):
|
|
"""to_dict plus the linked-asset summaries. Pass assetmap (assetid -> Asset)
|
|
to avoid a per-link query when serializing a list; without it, falls back to
|
|
a per-link get (fine for a single warranty)."""
|
|
data = warranty.to_dict(today)
|
|
assets = []
|
|
for link in warranty.links:
|
|
asset = assetmap.get(link.assetid) if assetmap is not None \
|
|
else 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.options(joinedload(Warranty.links))
|
|
.order_by(Warranty.enddate.is_(None), Warranty.enddate).all())
|
|
|
|
# Batch-fetch every linked asset in ONE query (was N+1: a db.session.get per
|
|
# link per warranty, ~1.8s for the full list).
|
|
assetids = {link.assetid for w in warranties for link in w.links}
|
|
assetmap = ({a.assetid: a for a in Asset.query.filter(Asset.assetid.in_(assetids)).all()}
|
|
if assetids else {})
|
|
|
|
today = date.today()
|
|
items = [_warranty_payload(w, today, assetmap) 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)
|
|
# Reuse a warranty that is Dell by ANY signal, not just
|
|
# provider: a manually-added or imported Dell warranty carries
|
|
# provider 'manual' (create default) but the same service tag or
|
|
# a "Dell" vendor. Matching only provider=='dell' made re-check
|
|
# duplicate every one of those instead of updating it.
|
|
if candidate and (
|
|
candidate.provider == 'dell'
|
|
or (candidate.servicetag or '').strip().upper() == tag
|
|
or (candidate.vendor or '').strip().lower() == 'dell'):
|
|
existing = candidate
|
|
break
|
|
if existing:
|
|
# Canonicalize to Dell so later re-checks match by provider and
|
|
# never fall through to a duplicate.
|
|
existing.provider = 'dell'
|
|
existing.vendor = existing.vendor or 'Dell'
|
|
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,
|
|
})
|