Files
shopdb-flask/plugins/warranty/services/providers.py
cproudlock b8c22244a1
Some checks failed
CI / backend (push) Failing after 2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.

Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
  mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
  prefixes, enable toggle. Defaults point at the current
  geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
  QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
  else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
  ships as the map default and sites upload their own blueprint.

Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
  via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).

Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
  CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
  UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
  MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.

Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
  (naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
  plugin contract purity) and the USB label page field mapping (both usb
  modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.

248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.

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

271 lines
9.9 KiB
Python

"""Warranty provider abstraction.
A provider looks up coverage for a unit by service tag / serial. Manual entry
needs no provider. Dell is a real integration (Dell TechDirect Warranty API,
OAuth2 client-credentials); Lenovo/HP remain config-shaped stubs until wired.
Per-vendor config lives in settings (warranty_<name>_*), masked where secret,
so nothing is hardcoded and the integration is toggled per site.
"""
import hashlib
import json
import os
import time
import requests
from flask import current_app
from shopdb.api import db
from shopdb.api import Setting
# Two-level Dell token cache. Dell rate-limits the token endpoint, so a fresh
# request per refresh (or per app restart) trips a 401 cooldown. Tokens live ~1h.
# L1 = process memory (fast); L2 = a file in the instance dir so the token
# survives restarts and is shared across workers - mirrors warranty_sync.py.
_dell_token_cache = {}
def _token_file(clientid):
# Per-client file, name keyed by a hash so the id never lands on disk.
tag = hashlib.sha256((clientid or '').encode()).hexdigest()[:16]
return os.path.join(current_app.instance_path, f'.dell_token_{tag}.json')
def _read_file_token(clientid):
try:
with open(_token_file(clientid)) as handle:
cached = json.load(handle)
if cached.get('expires_at', 0) > time.time() + 60:
return cached['token']
except (OSError, ValueError, KeyError):
pass
return None
def _write_file_token(clientid, token, expires_at):
try:
os.makedirs(current_app.instance_path, exist_ok=True)
with open(_token_file(clientid), 'w') as handle:
json.dump({'token': token, 'expires_at': expires_at}, handle)
except OSError:
pass
# Dell TechDirect defaults, overridable via settings if Dell changes endpoints.
# The asset-entitlements service lives under sbil/eapi (device.warranty path 404s
# with "Service Not Found" for this account). Verified against the live API.
DELL_TOKEN_URL = 'https://apigtwb2c.us.dell.com/auth/oauth/v2/token'
DELL_API_URL = 'https://apigtwb2c.us.dell.com/PROD/sbil/eapi/v5/asset-entitlements'
HTTP_TIMEOUT = 15 # seconds
class ProviderNotConfigured(Exception):
"""Raised when a provider is asked to look up but has no API config."""
pass
class WarrantyLookupError(Exception):
"""Raised when a configured provider's lookup fails at runtime."""
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
def _flag(key):
return str(_setting(key, 'false')).lower() == 'true'
class WarrantyProvider:
"""Base provider. name matches Warranty.provider values."""
name = 'base'
def lookup(self, servicetag, vendor=None):
"""Return {servicelevel, startdate, enddate} or None.
Raises ProviderNotConfigured when creds are missing, WarrantyLookupError
when a configured lookup fails.
"""
raise NotImplementedError
class ManualProvider(WarrantyProvider):
"""No external lookup - values are entered by hand."""
name = 'manual'
def lookup(self, servicetag, vendor=None):
return None
class DellProvider(WarrantyProvider):
"""Dell TechDirect Warranty API v5 (OAuth2 client-credentials)."""
name = 'dell'
def _config(self):
return {
'enabled': _flag('warranty_dell_enabled'),
'clientid': _setting('warranty_dell_clientid'),
'clientsecret': _setting('warranty_dell_clientsecret'),
'tokenurl': _setting('warranty_dell_tokenurl', DELL_TOKEN_URL),
'apiurl': _setting('warranty_dell_apiurl', DELL_API_URL),
}
def _get_token(self, config):
clientid = config['clientid']
# L1: process memory.
cached = _dell_token_cache.get(clientid)
if cached and cached['expires_at'] > time.time() + 60:
return cached['token']
# L2: file cache (survives restarts / shared across workers).
file_token = _read_file_token(clientid)
if file_token:
return file_token
# Dell expects the client id/secret as HTTP Basic auth, grant type in the
# body. (Passing them in the body trips the token endpoint's rate limiter.)
try:
response = requests.post(
config['tokenurl'],
auth=(config['clientid'], config['clientsecret']),
data={'grant_type': 'client_credentials'},
timeout=HTTP_TIMEOUT,
)
except requests.RequestException as exc:
raise WarrantyLookupError(f'Dell auth failed: {exc}')
if response.status_code in (401, 429):
raise WarrantyLookupError(
'Dell token endpoint is rate-limiting (cooldown). Wait a few '
'minutes and try again - it caches once obtained.'
)
try:
response.raise_for_status()
token = response.json().get('access_token')
expires_in = int(response.json().get('expires_in', 3600))
except (requests.RequestException, ValueError) as exc:
raise WarrantyLookupError(f'Dell auth failed: {exc}')
if not token:
raise WarrantyLookupError('Dell auth returned no access_token')
expires_at = time.time() + expires_in - 60
_dell_token_cache[clientid] = {'token': token, 'expires_at': expires_at}
_write_file_token(clientid, token, expires_at)
return token
def _fetch(self, config, token, servicetag):
try:
response = requests.get(
config['apiurl'],
params={'servicetags': servicetag},
headers={'Authorization': f'Bearer {token}', 'Accept': 'application/json'},
timeout=HTTP_TIMEOUT,
)
response.raise_for_status()
return response.json()
except (requests.RequestException, ValueError) as exc:
raise WarrantyLookupError(f'Dell warranty lookup failed: {exc}')
@staticmethod
def _map(payload):
"""Reduce the Dell response to {servicelevel, startdate, enddate}.
Dell returns a list of assets, each with an 'entitlements' list. Coverage
spans the earliest start to the latest end across entitlements.
"""
assets = payload if isinstance(payload, list) else [payload]
entitlements = []
for asset in assets:
if asset.get('invalid'):
continue
entitlements.extend(asset.get('entitlements') or [])
# Only entitlements with an end date define coverage.
dated = [e for e in entitlements if e.get('endDate')]
if not dated:
return None
starts = [e['startDate'] for e in entitlements if e.get('startDate')]
latest = max(dated, key=lambda e: e['endDate'])
return {
'servicelevel': latest.get('serviceLevelDescription') or latest.get('serviceLevelCode'),
'startdate': min(starts)[:10] if starts else None,
'enddate': max(e['endDate'] for e in dated)[:10],
}
def lookup(self, servicetag, vendor=None):
config = self._config()
if not (config['enabled'] and config['clientid'] and config['clientsecret']):
raise ProviderNotConfigured(
'Dell warranty lookup is not configured. Set warranty_dell_enabled, '
'clientid and clientsecret in Settings > Integrations.'
)
if not servicetag:
raise WarrantyLookupError('Dell lookup needs a service tag on the warranty.')
token = self._get_token(config)
return self._map(self._fetch(config, token, servicetag))
def bulk_lookup(self, servicetags):
"""Look up many tags at once. Dell takes up to 100 tags per call.
Returns {SERVICETAG_UPPER: {servicelevel, startdate, enddate}} for tags
Dell recognizes; unknown/non-Dell tags are simply absent.
"""
config = self._config()
if not (config['enabled'] and config['clientid'] and config['clientsecret']):
raise ProviderNotConfigured(
'Dell warranty lookup is not configured. Set warranty_dell_enabled, '
'clientid and clientsecret in Settings > Integrations.'
)
tags = [t.strip() for t in servicetags if t and t.strip()]
if not tags:
return {}
token = self._get_token(config)
results = {}
for start in range(0, len(tags), 100):
chunk = tags[start:start + 100]
payload = self._fetch(config, token, ','.join(chunk))
assets = payload if isinstance(payload, list) else [payload]
for asset in assets:
tag = (asset.get('serviceTag') or '').upper()
mapped = self._map([asset])
if tag and mapped:
results[tag] = mapped
return results
class ApiProvider(WarrantyProvider):
"""Generic config-shaped stub for vendors not yet wired (Lenovo, HP)."""
def lookup(self, servicetag, vendor=None):
enabled = _flag(f'warranty_{self.name}_enabled')
url = _setting(f'warranty_{self.name}_apiurl')
token = _setting(f'warranty_{self.name}_apitoken')
if not (enabled and url and token):
raise ProviderNotConfigured(
f'{self.name} warranty lookup is not configured.'
)
raise ProviderNotConfigured(
f'{self.name} API lookup not implemented yet (config present).'
)
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())