"""Dell warranty sync, callable from a request or from the CLI. Extracted from the POST /sync/dell route so the same logic can run in bounded chunks off the request path. `bulk_lookup` calls Dell 100 tags at a time at HTTP_TIMEOUT seconds each, so an unbounded fleet-sized sync is dozens of sequential HTTP calls inside one web request - long enough for a gunicorn worker timeout or a proxy to kill it first, leaving the user with a failure and no idea what landed. The sync is resumable by design: assets that already hold a dated warranty are skipped, so a run that covers part of the fleet simply leaves the rest for the next run. That is what makes chunking safe - no job queue, no progress table, no state machine. Runs are operator-driven (the sync button, or `flask warranty sync-dell` for a big first-run backfill); nothing drains the remainder on a schedule, so callers report `remaining` and ask for another run. """ import random from datetime import datetime, timezone from shopdb.api import db, Asset, DateValueError, parse_date from ..models import Warranty, WarrantyAsset from .providers import get_provider # One Dell call is 100 tags. The synchronous route stays a couple of calls deep # so it cannot outlive a worker timeout; the CLI default is one call per run. ROUTE_SYNC_LIMIT = 200 CLI_SYNC_LIMIT = 100 def _vendor_date(value): """A date off a VENDOR response, which this code does not control. Kept lenient on purpose, unlike a date a person typed: a provider that starts returning a shape we do not parse must not fail the refresh of an otherwise good record. The field is simply left as it was. """ try: return parse_date(value) except DateValueError: return None def collect_pending_tags(recheck_all=False): """Service tags still wanting a Dell lookup, mapped to their asset ids.""" covered = set() if not recheck_all: for link in WarrantyAsset.query.all(): warranty = db.session.get(Warranty, link.warrantyid) if warranty and warranty.isactive and warranty.enddate: covered.add(link.assetid) 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) return by_tag def run_dell_sync(limit=None, recheck_all=False): """Look up service tags that have no warranty yet and record what Dell knows. `limit` caps how many TAGS are sent to Dell in this run. Returns the same counters the route has always returned plus `remaining`, so a caller can tell the user how much of the fleet is still pending. Raises ProviderNotConfigured / WarrantyLookupError from the provider; the caller decides how to surface those. """ provider = get_provider('dell') by_tag = collect_pending_tags(recheck_all=recheck_all) pending = len(by_tag) if not by_tag: return {'candidates': 0, 'tags': 0, 'matched': 0, 'created': 0, 'updated': 0, 'remaining': 0} tags = list(by_tag.keys()) if limit is not None and limit < len(tags): # Shuffle before truncating. A serial Dell does not recognize never # gains a warranty, so it stays pending forever - and under a stable # order those same unrecognized tags would occupy every chunk and the # sync would never reach the rest of the fleet. Random selection makes # progress regardless of how many dead serials are in the set. tags = random.sample(tags, limit) by_tag = {tag: by_tag[tag] for tag in tags} # by_tag is plain strings and ints, so it survives the commit. Release the # connection before bulk_lookup: Dell is called 100 tags at a time and a # fleet-sized sync would otherwise pin a pooled connection for minutes. db.session.commit() results = provider.bulk_lookup(tags) # Preload every warranty already linked to a candidate asset in one query. # Done per asset this was a query per asset plus one per link, which would # just trade the long network hold for a long write transaction. matched_assetids = [assetid for tag, assetids in by_tag.items() if results.get(tag) for assetid in assetids] existing_by_assetid = {} if matched_assetids: rows = (db.session.query(WarrantyAsset.assetid, Warranty) .join(Warranty, Warranty.warrantyid == WarrantyAsset.warrantyid) .filter(WarrantyAsset.assetid.in_(matched_assetids)) .all()) for assetid, warranty in rows: existing_by_assetid.setdefault(assetid, []).append(warranty) 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 candidate in existing_by_assetid.get(assetid, []): # 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.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 = _vendor_date(found.get('startdate')) existing.enddate = _vendor_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=_vendor_date(found.get('startdate')), enddate=_vendor_date(found.get('enddate')), lastcheckeddate=now, ) warranty.links.append(WarrantyAsset(assetid=assetid)) db.session.add(warranty) created += 1 db.session.commit() return { 'candidates': sum(len(v) for v in by_tag.values()), 'tags': len(by_tag), 'matched': matched, 'created': created, 'updated': updated, 'remaining': max(pending - len(by_tag), 0), }