Warranty phase 2: real Dell provider + settings UI + PC hero badge
- DellProvider: real Dell TechDirect lookup. OAuth2 via HTTP Basic auth, asset-entitlements under /PROD/sbil/eapi/v5 (the device.warranty path 404s for this account), map latest dated entitlement to service level + dates. Cache the token process-wide; Dell rate-limits the token endpoint and a fresh request per refresh trips a 401 cooldown. Verified against live Dell. - Lenovo/HP stay config-shaped stubs. - Settings: warranty_dell_* keys (category integrations); Dell Warranty Lookup block in System Settings > Integrations (enable + client id/secret masked + optional token/API URL overrides). - WarrantyPanel takes optional pre-fetched items; PCDetail fetches once and feeds both the panel and a new hero warranty-status/end-date badge. - tools/mock_dell.py for offline testing of the provider flow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@ from shopdb.api import (
|
||||
)
|
||||
|
||||
from ..models import Warranty, WarrantyAsset
|
||||
from ..services import get_provider, ProviderNotConfigured
|
||||
from ..services import get_provider, ProviderNotConfigured, WarrantyLookupError
|
||||
|
||||
warranty_bp = Blueprint('warranty', __name__)
|
||||
|
||||
@@ -194,7 +194,7 @@ def refresh_warranty(warrantyid):
|
||||
provider = get_provider(warranty.provider)
|
||||
try:
|
||||
result = provider.lookup(warranty.servicetag, warranty.vendor)
|
||||
except ProviderNotConfigured as exc:
|
||||
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,
|
||||
|
||||
@@ -4,6 +4,7 @@ from .providers import (
|
||||
get_provider,
|
||||
provider_names,
|
||||
ProviderNotConfigured,
|
||||
WarrantyLookupError,
|
||||
)
|
||||
|
||||
__all__ = ['get_provider', 'provider_names', 'ProviderNotConfigured']
|
||||
__all__ = ['get_provider', 'provider_names', 'ProviderNotConfigured', 'WarrantyLookupError']
|
||||
|
||||
@@ -1,34 +1,61 @@
|
||||
"""Warranty provider abstraction.
|
||||
|
||||
A provider looks up coverage for a unit by service tag / serial. Phase 1 ships
|
||||
manual entry plus provider stubs that read per-vendor API config from settings.
|
||||
When a real API (Dell TechDirect, Lenovo, HP) is wired later, only the matching
|
||||
provider's lookup() body changes - callers and the API surface stay the same.
|
||||
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 time
|
||||
|
||||
import requests
|
||||
|
||||
from shopdb.api import db
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
# Process-wide Dell token cache. Dell rate-limits the token endpoint, so a fresh
|
||||
# request per refresh trips a 401 cooldown. Tokens live ~1h; cache + reuse.
|
||||
_dell_token_cache = {}
|
||||
|
||||
# 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 a dict of {servicelevel, startdate, enddate} or None.
|
||||
"""Return {servicelevel, startdate, enddate} or None.
|
||||
|
||||
Raises ProviderNotConfigured when the provider needs API creds it does
|
||||
not have.
|
||||
Raises ProviderNotConfigured when creds are missing, WarrantyLookupError
|
||||
when a configured lookup fails.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -41,36 +68,121 @@ class ManualProvider(WarrantyProvider):
|
||||
return None
|
||||
|
||||
|
||||
class ApiProvider(WarrantyProvider):
|
||||
"""Common shape for vendor API providers. Reads enabled/url/token from
|
||||
settings under warranty_<name>_*. Real HTTP call is deferred to a later
|
||||
phase; today it fails loud if asked to look up so manual entry is unaffected.
|
||||
"""
|
||||
class DellProvider(WarrantyProvider):
|
||||
"""Dell TechDirect Warranty API v5 (OAuth2 client-credentials)."""
|
||||
name = 'dell'
|
||||
|
||||
def _config(self):
|
||||
enabled = str(_setting(f'warranty_{self.name}_enabled', 'false')).lower() == 'true'
|
||||
url = _setting(f'warranty_{self.name}_apiurl')
|
||||
token = _setting(f'warranty_{self.name}_apitoken')
|
||||
return enabled, url, token
|
||||
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):
|
||||
# Reuse a cached token while valid - Dell rate-limits the token endpoint.
|
||||
cached = _dell_token_cache.get(config['clientid'])
|
||||
if cached and cached['expires_at'] > time.time() + 60:
|
||||
return cached['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')
|
||||
_dell_token_cache[config['clientid']] = {
|
||||
'token': token, 'expires_at': time.time() + expires_in - 60,
|
||||
}
|
||||
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):
|
||||
enabled, url, token = self._config()
|
||||
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))
|
||||
|
||||
|
||||
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. '
|
||||
f'Set warranty_{self.name}_enabled/apiurl/apitoken in Settings.'
|
||||
f'{self.name} warranty lookup is not configured.'
|
||||
)
|
||||
# Phase 2+: perform the vendor API call here and map the response to
|
||||
# {servicelevel, startdate, enddate}. Until then, signal not-yet-built.
|
||||
raise ProviderNotConfigured(
|
||||
f'{self.name} API lookup not implemented yet (config present).'
|
||||
)
|
||||
|
||||
|
||||
class DellProvider(ApiProvider):
|
||||
name = 'dell'
|
||||
|
||||
|
||||
class LenovoProvider(ApiProvider):
|
||||
name = 'lenovo'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user