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:
cproudlock
2026-07-09 16:05:26 -04:00
parent 78a0ee8d83
commit 7e9650a62b
8 changed files with 370 additions and 30 deletions

View File

@@ -33,6 +33,9 @@ const props = defineProps({
assetid: { type: [Number, String], default: null }, assetid: { type: [Number, String], default: null },
// When true, render the card even with no warranties (shows an "Add one" link). // When true, render the card even with no warranties (shows an "Add one" link).
showEmpty: { type: Boolean, default: false }, showEmpty: { type: Boolean, default: false },
// Optional pre-fetched warranties. When provided, the panel uses them instead
// of fetching (lets a parent share one fetch with e.g. a hero badge).
items: { type: Array, default: null },
}) })
const warranties = ref([]) const warranties = ref([])
@@ -47,6 +50,8 @@ function formatDate(d) {
} }
async function load() { async function load() {
// Parent-supplied data wins - skip the fetch.
if (props.items !== null) { warranties.value = props.items; return }
if (!props.assetid) { warranties.value = []; return } if (!props.assetid) { warranties.value = []; return }
try { try {
const response = await warrantyApi.forAsset(props.assetid) const response = await warrantyApi.forAsset(props.assetid)
@@ -59,6 +64,7 @@ async function load() {
onMounted(load) onMounted(load)
watch(() => props.assetid, load) watch(() => props.assetid, load)
watch(() => props.items, load)
</script> </script>
<style scoped> <style scoped>

View File

@@ -23,6 +23,10 @@
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)"> <span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
{{ computer.statusname || 'Unknown' }} {{ computer.statusname || 'Unknown' }}
</span> </span>
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ formatWarrantyDate(heroWarranty.enddate) }}</template>
</span>
</div> </div>
<div class="hero-details"> <div class="hero-details">
<div class="hero-detail" v-if="computer.computer?.computertypename"> <div class="hero-detail" v-if="computer.computer?.computertypename">
@@ -223,7 +227,7 @@
<CustomFieldsSection :assetid="computer.assetid" /> <CustomFieldsSection :assetid="computer.assetid" />
<!-- Warranty --> <!-- Warranty -->
<WarrantyPanel :assetid="computer.assetid" /> <WarrantyPanel :assetid="computer.assetid" :items="warranties" />
<!-- Notes --> <!-- Notes -->
<div class="section-card" v-if="computer.notes"> <div class="section-card" v-if="computer.notes">
@@ -250,7 +254,7 @@
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { colorStyle } from "@/utils/colorStyle" import { colorStyle } from "@/utils/colorStyle"
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { computersApi, applicationsApi, assetsApi } from '../../api' import { computersApi, applicationsApi, assetsApi, warrantyApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue' import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue' import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue' import WarrantyPanel from '../../components/WarrantyPanel.vue'
@@ -263,6 +267,21 @@ const loading = ref(true)
const computer = ref(null) const computer = ref(null)
const relationships = ref({ incoming: [], outgoing: [] }) const relationships = ref({ incoming: [], outgoing: [] })
const installedApps = ref([]) const installedApps = ref([])
const warranties = ref([])
// Worst-case warranty for the hero badge: expired > expiring > active > unknown.
const heroWarranty = computed(() => {
if (!warranties.value.length) return null
const rank = { expired: 0, expiring: 1, active: 2, unknown: 3 }
const worst = [...warranties.value].sort((a, b) => (rank[a.status] ?? 9) - (rank[b.status] ?? 9))[0]
const labels = { active: 'Under Warranty', expiring: 'Warranty Expiring', expired: 'Warranty Expired', unknown: 'Warranty' }
return { ...worst, label: labels[worst.status] || 'Warranty' }
})
function formatWarrantyDate(d) {
if (!d) return ''
return new Date(d + 'T00:00:00').toLocaleDateString()
}
const controlledEquipment = computed(() => { const controlledEquipment = computed(() => {
// For computers, find related equipment in any "Controls" relationship // For computers, find related equipment in any "Controls" relationship
@@ -315,6 +334,16 @@ onMounted(async () => {
} catch (appError) { } catch (appError) {
console.log('No installed apps data:', appError.message) console.log('No installed apps data:', appError.message)
} }
// Load warranties (one fetch; feeds both the hero badge and the panel)
if (computer.value?.assetid) {
try {
const warrantyResponse = await warrantyApi.forAsset(computer.value.assetid)
warranties.value = warrantyResponse.data.data || []
} catch (warrantyError) {
console.log('No warranty data:', warrantyError.message)
}
}
} catch (error) { } catch (error) {
console.error('Error loading computer:', error) console.error('Error loading computer:', error)
} finally { } finally {

View File

@@ -77,6 +77,86 @@
<span>{{ zabbixMessage }}</span> <span>{{ zabbixMessage }}</span>
</div> </div>
</div> </div>
<div class="setting-group">
<h3>Dell Warranty Lookup</h3>
<p class="setting-description">
Look up Dell coverage by service tag via the Dell TechDirect Warranty API.
When enabled, the Refresh button on a Dell warranty pulls the current service
level and end date. Requires a Dell TechDirect API client id and secret.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Enable Dell Warranty Lookup</span>
<button
class="toggle-btn"
:class="{ active: settings.warranty_dell_enabled }"
@click="toggleSetting('warranty_dell_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<div class="setting-row" v-if="settings.warranty_dell_enabled">
<label>
<span>Client ID</span>
<input
type="text"
v-model="settings.warranty_dell_clientid"
placeholder="Dell TechDirect client id"
@blur="saveSetting('warranty_dell_clientid', settings.warranty_dell_clientid)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row" v-if="settings.warranty_dell_enabled">
<label>
<span>Client Secret</span>
<input
type="password"
v-model="settings.warranty_dell_clientsecret"
placeholder="Enter client secret"
@blur="saveSetting('warranty_dell_clientsecret', settings.warranty_dell_clientsecret)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row" v-if="settings.warranty_dell_enabled">
<label>
<span>Token URL <small>(blank = Dell default)</small></span>
<input
type="url"
v-model="settings.warranty_dell_tokenurl"
placeholder="https://apigtwb2c.us.dell.com/auth/oauth/v2/token"
@blur="saveSetting('warranty_dell_tokenurl', settings.warranty_dell_tokenurl)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row" v-if="settings.warranty_dell_enabled">
<label>
<span>API URL <small>(blank = Dell default)</small></span>
<input
type="url"
v-model="settings.warranty_dell_apiurl"
placeholder="https://apigtwb2c.us.dell.com/PROD/sbfxapp/device.warranty/v5/asset-entitlements"
@blur="saveSetting('warranty_dell_apiurl', settings.warranty_dell_apiurl)"
:disabled="saving"
>
</label>
</div>
<div class="status-indicator" v-if="settings.warranty_dell_enabled">
<span class="status-dot" :class="dellStatus"></span>
<span>{{ dellMessage }}</span>
</div>
</div>
</div> </div>
<!-- Email Section --> <!-- Email Section -->
@@ -625,6 +705,11 @@ const settings = reactive({
zabbix_enabled: false, zabbix_enabled: false,
zabbix_url: '', zabbix_url: '',
zabbix_token: '', zabbix_token: '',
warranty_dell_enabled: false,
warranty_dell_clientid: '',
warranty_dell_clientsecret: '',
warranty_dell_tokenurl: '',
warranty_dell_apiurl: '',
// Email // Email
smtp_enabled: false, smtp_enabled: false,
smtp_host: '', smtp_host: '',
@@ -720,6 +805,20 @@ const zabbixMessage = computed(() => {
return 'Configured (connectivity checked on first use)' return 'Configured (connectivity checked on first use)'
}) })
// Dell warranty status
const dellStatus = computed(() => {
if (!settings.warranty_dell_enabled) return 'inactive'
if (!settings.warranty_dell_clientid || !settings.warranty_dell_clientsecret) return 'warning'
return 'pending'
})
const dellMessage = computed(() => {
if (!settings.warranty_dell_enabled) return 'Disabled'
if (!settings.warranty_dell_clientid) return 'Client id not configured'
if (!settings.warranty_dell_clientsecret) return 'Client secret not configured'
return 'Configured (used when you refresh a Dell warranty)'
})
// SMTP status // SMTP status
const smtpStatus = computed(() => { const smtpStatus = computed(() => {
if (!settings.smtp_enabled) return 'inactive' if (!settings.smtp_enabled) return 'inactive'

View File

@@ -17,7 +17,7 @@ from shopdb.api import (
) )
from ..models import Warranty, WarrantyAsset from ..models import Warranty, WarrantyAsset
from ..services import get_provider, ProviderNotConfigured from ..services import get_provider, ProviderNotConfigured, WarrantyLookupError
warranty_bp = Blueprint('warranty', __name__) warranty_bp = Blueprint('warranty', __name__)
@@ -194,7 +194,7 @@ def refresh_warranty(warrantyid):
provider = get_provider(warranty.provider) provider = get_provider(warranty.provider)
try: try:
result = provider.lookup(warranty.servicetag, warranty.vendor) 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) return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
if not result: if not result:
return error_response(ErrorCodes.VALIDATION_ERROR, return error_response(ErrorCodes.VALIDATION_ERROR,

View File

@@ -4,6 +4,7 @@ from .providers import (
get_provider, get_provider,
provider_names, provider_names,
ProviderNotConfigured, ProviderNotConfigured,
WarrantyLookupError,
) )
__all__ = ['get_provider', 'provider_names', 'ProviderNotConfigured'] __all__ = ['get_provider', 'provider_names', 'ProviderNotConfigured', 'WarrantyLookupError']

View File

@@ -1,34 +1,61 @@
"""Warranty provider abstraction. """Warranty provider abstraction.
A provider looks up coverage for a unit by service tag / serial. Phase 1 ships A provider looks up coverage for a unit by service tag / serial. Manual entry
manual entry plus provider stubs that read per-vendor API config from settings. needs no provider. Dell is a real integration (Dell TechDirect Warranty API,
When a real API (Dell TechDirect, Lenovo, HP) is wired later, only the matching OAuth2 client-credentials); Lenovo/HP remain config-shaped stubs until wired.
provider's lookup() body changes - callers and the API surface stay the same.
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.api import db
from shopdb.core.models import Setting 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): class ProviderNotConfigured(Exception):
"""Raised when a provider is asked to look up but has no API config.""" """Raised when a provider is asked to look up but has no API config."""
pass pass
class WarrantyLookupError(Exception):
"""Raised when a configured provider's lookup fails at runtime."""
pass
def _setting(key, default=None): def _setting(key, default=None):
row = Setting.query.filter_by(key=key).first() row = Setting.query.filter_by(key=key).first()
return row.value if row and row.value not in (None, '') else default 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: class WarrantyProvider:
"""Base provider. name matches Warranty.provider values.""" """Base provider. name matches Warranty.provider values."""
name = 'base' name = 'base'
def lookup(self, servicetag, vendor=None): 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 Raises ProviderNotConfigured when creds are missing, WarrantyLookupError
not have. when a configured lookup fails.
""" """
raise NotImplementedError raise NotImplementedError
@@ -41,36 +68,121 @@ class ManualProvider(WarrantyProvider):
return None return None
class ApiProvider(WarrantyProvider): class DellProvider(WarrantyProvider):
"""Common shape for vendor API providers. Reads enabled/url/token from """Dell TechDirect Warranty API v5 (OAuth2 client-credentials)."""
settings under warranty_<name>_*. Real HTTP call is deferred to a later name = 'dell'
phase; today it fails loud if asked to look up so manual entry is unaffected.
"""
def _config(self): def _config(self):
enabled = str(_setting(f'warranty_{self.name}_enabled', 'false')).lower() == 'true' return {
url = _setting(f'warranty_{self.name}_apiurl') 'enabled': _flag('warranty_dell_enabled'),
token = _setting(f'warranty_{self.name}_apitoken') 'clientid': _setting('warranty_dell_clientid'),
return enabled, url, token '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): 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): if not (enabled and url and token):
raise ProviderNotConfigured( raise ProviderNotConfigured(
f'{self.name} warranty lookup is not configured. ' f'{self.name} warranty lookup is not configured.'
f'Set warranty_{self.name}_enabled/apiurl/apitoken in Settings.'
) )
# Phase 2+: perform the vendor API call here and map the response to
# {servicelevel, startdate, enddate}. Until then, signal not-yet-built.
raise ProviderNotConfigured( raise ProviderNotConfigured(
f'{self.name} API lookup not implemented yet (config present).' f'{self.name} API lookup not implemented yet (config present).'
) )
class DellProvider(ApiProvider):
name = 'dell'
class LenovoProvider(ApiProvider): class LenovoProvider(ApiProvider):
name = 'lenovo' name = 'lenovo'

View File

@@ -301,6 +301,42 @@ def build_default_settings():
'category': 'integrations', 'category': 'integrations',
'description': 'Zabbix API authentication token' 'description': 'Zabbix API authentication token'
}, },
# Dell warranty lookup (Dell TechDirect Warranty API, OAuth2)
{
'key': 'warranty_dell_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'integrations',
'description': 'Enable Dell warranty lookups (service-tag entitlements)'
},
{
'key': 'warranty_dell_clientid',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'Dell TechDirect API client id'
},
{
'key': 'warranty_dell_clientsecret',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'Dell TechDirect API client secret'
},
{
'key': 'warranty_dell_tokenurl',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'Dell OAuth token URL (blank = Dell default)'
},
{
'key': 'warranty_dell_apiurl',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'Dell warranty API URL (blank = Dell default)'
},
# Email/SMTP settings # Email/SMTP settings
{ {
'key': 'smtp_enabled', 'key': 'smtp_enabled',

57
tools/mock_dell.py Normal file
View File

@@ -0,0 +1,57 @@
"""Minimal mock of the Dell TechDirect Warranty API for local testing.
POST /token -> {access_token, expires_in}
GET /asset-entitlements?servicetags=TAG -> one asset with two entitlements
Run: venv/bin/python tools/mock_dell.py 8899
"""
import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
class Handler(BaseHTTPRequestHandler):
def _send(self, payload, code=200):
body = json.dumps(payload).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
if urlparse(self.path).path.rstrip('/').endswith('token'):
self._send({'access_token': 'mock-access-token', 'expires_in': 3600})
else:
self._send({'error': 'not found'}, 404)
def do_GET(self):
parsed = urlparse(self.path)
if 'asset-entitlements' in parsed.path:
tag = (parse_qs(parsed.query).get('servicetags') or ['UNKNOWN'])[0]
if self.headers.get('Authorization') != 'Bearer mock-access-token':
self._send({'error': 'unauthorized'}, 401)
return
self._send([{
'serviceTag': tag,
'productLineDescription': 'LATITUDE 5540',
'shipDate': '2024-01-10T00:00:00Z',
'entitlements': [
{'serviceLevelDescription': 'Basic Onsite',
'startDate': '2024-01-15T00:00:00Z', 'endDate': '2025-01-15T00:00:00Z'},
{'serviceLevelDescription': 'ProSupport Plus',
'startDate': '2024-01-15T00:00:00Z', 'endDate': '2027-01-15T00:00:00Z'},
],
}])
else:
self._send({'error': 'not found'}, 404)
def log_message(self, *args):
pass
if __name__ == '__main__':
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8899
HTTPServer(('127.0.0.1', port), Handler).serve_forever()