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:
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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
57
tools/mock_dell.py
Normal 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()
|
||||||
Reference in New Issue
Block a user