From the full multi-agent review (0 high, 7 medium, 17 low findings). Applies the mechanical, low-risk items; design/policy findings left for a decision. Docs accuracy: CLAUDE.md contract 0.10.0 -> 0.11.0 and both stale Alembic head citations -> 7d24_customfield_searchable / 31 migrations; Dockerfile bundled- plugin comment fixed (drop nonexistent "equipment", add machines + measuringtools, count eleven). Style/naming (LOCKED rules): remove a CSS-escaped pushpin emoji before location search results (no-emoji policy); rename ManifestEditor shareRoot -> shareroot (variable mirrors the API field verbatim). Dead code: remove confirmed-unused imports across ~20 modules (require_role/ require_permission scaffold residue, stray db/Vendor/Model/current_user/Optional/ error_response); drop unused build_scope import + a stale GEENFORCE_API_KEY docstring clause in geenforce. Migration files left untouched. Correctness: geenforce ingest robustness - record_enforcement_report now 400s on a non-dict counts / non-list results instead of 500; _apply_app_link ignores a non-numeric appid per its docstring instead of 500. Regression tests added. Backend query.get sweep finished: auth.py refresh -> db.session.get (last one). 910 backend tests pass; pyflakes clean; naming green; frontend build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
270 lines
9.9 KiB
Python
270 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 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())
|