Initial import: shopdb-flask multi-site asset platform

Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
"""Warranty plugin package."""
from .plugin import WarrantyPlugin
__all__ = ['WarrantyPlugin']

View File

@@ -0,0 +1,5 @@
"""Warranty plugin API."""
from .routes import warranty_bp
__all__ = ['warranty_bp']

View File

@@ -0,0 +1,336 @@
"""Warranty API: manual CRUD now, provider refresh stubbed for later phases.
Coverage status is derived from enddate at read time (see models.derive_status),
never stored. Warranties link to assets many-to-many via warrantyassets, though
the common case is one warranty per asset.
"""
from datetime import date, datetime, timezone
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from sqlalchemy.orm import joinedload
from shopdb.api import (
db, Asset,
success_response, error_response, ErrorCodes,
require_permission,
)
from ..models import Warranty, WarrantyAsset
from ..services import get_provider, ProviderNotConfigured, WarrantyLookupError
warranty_bp = Blueprint('warranty', __name__)
def _parse_date(value):
"""Accept 'YYYY-MM-DD' (or None/empty) -> date or None."""
if not value:
return None
try:
return datetime.strptime(value[:10], '%Y-%m-%d').date()
except (ValueError, TypeError):
return None
def _asset_summary(asset):
return {
'assetid': asset.assetid,
'assetnumber': asset.assetnumber,
'name': asset.name,
'assettypename': asset.assettype.assettype if asset.assettype else None,
}
def _warranty_payload(warranty, today=None, assetmap=None):
"""to_dict plus the linked-asset summaries. Pass assetmap (assetid -> Asset)
to avoid a per-link query when serializing a list; without it, falls back to
a per-link get (fine for a single warranty)."""
data = warranty.to_dict(today)
assets = []
for link in warranty.links:
asset = assetmap.get(link.assetid) if assetmap is not None \
else db.session.get(Asset, link.assetid)
if asset:
assets.append(_asset_summary(asset))
data['assets'] = assets
return data
def _apply_links(warranty, assetids):
"""Replace a warranty's asset links with the given asset id list."""
if assetids is None:
return
wanted = {int(a) for a in assetids if str(a).strip()}
existing = {link.assetid: link for link in warranty.links}
for assetid in wanted - set(existing):
if db.session.get(Asset, assetid):
warranty.links.append(WarrantyAsset(assetid=assetid))
for assetid in set(existing) - wanted:
warranty.links.remove(existing[assetid])
# =============================================================================
# CRUD
# =============================================================================
@warranty_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_warranties():
"""List warranties. Filters: ?status=, ?assetid=, ?active=false."""
query = Warranty.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter_by(isactive=True)
# Exact-match natural-key lookup for idempotent import (servicetag + vendor).
if exactservicetag := request.args.get('servicetag'):
query = query.filter(Warranty.servicetag == exactservicetag)
if exactvendor := request.args.get('vendor'):
query = query.filter(Warranty.vendor == exactvendor)
assetid = request.args.get('assetid', type=int)
if assetid:
query = (query.join(WarrantyAsset, WarrantyAsset.warrantyid == Warranty.warrantyid)
.filter(WarrantyAsset.assetid == assetid))
warranties = (query.options(joinedload(Warranty.links))
.order_by(Warranty.enddate.is_(None), Warranty.enddate).all())
# Batch-fetch every linked asset in ONE query (was N+1: a db.session.get per
# link per warranty, ~1.8s for the full list).
assetids = {link.assetid for w in warranties for link in w.links}
assetmap = ({a.assetid: a for a in Asset.query.filter(Asset.assetid.in_(assetids)).all()}
if assetids else {})
today = date.today()
items = [_warranty_payload(w, today, assetmap) for w in warranties]
status_filter = request.args.get('status')
if status_filter:
items = [i for i in items if i['status'] == status_filter]
return success_response(items)
@warranty_bp.route('/asset/<int:assetid>', methods=['GET'])
@jwt_required(optional=True)
def warranties_for_asset(assetid):
"""Warranties covering one asset (for the asset-detail panel)."""
links = WarrantyAsset.query.filter_by(assetid=assetid).all()
today = date.today()
items = []
for link in links:
w = db.session.get(Warranty, link.warrantyid)
if w and w.isactive:
items.append(_warranty_payload(w, today))
return success_response(items)
@warranty_bp.route('/<int:warrantyid>', methods=['GET'])
@jwt_required(optional=True)
def get_warranty(warrantyid):
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
return success_response(_warranty_payload(warranty))
@warranty_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('warranty.create')
def create_warranty():
data = request.get_json() or {}
vendor = (data.get('vendor') or '').strip()
if not vendor:
return error_response(ErrorCodes.VALIDATION_ERROR, 'vendor is required')
warranty = Warranty(
vendor=vendor,
servicetag=(data.get('servicetag') or '').strip() or None,
provider=(data.get('provider') or 'manual').strip().lower(),
servicelevel=(data.get('servicelevel') or '').strip() or None,
startdate=_parse_date(data.get('startdate')),
enddate=_parse_date(data.get('enddate')),
notes=(data.get('notes') or '').strip() or None,
)
_apply_links(warranty, data.get('assetids'))
db.session.add(warranty)
db.session.commit()
return success_response(_warranty_payload(warranty), message='Warranty created', http_code=201)
@warranty_bp.route('/<int:warrantyid>', methods=['PUT'])
@jwt_required()
@require_permission('warranty.edit')
def update_warranty(warrantyid):
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
data = request.get_json() or {}
if 'vendor' in data:
warranty.vendor = (data['vendor'] or '').strip() or warranty.vendor
if 'servicetag' in data:
warranty.servicetag = (data['servicetag'] or '').strip() or None
if 'provider' in data:
warranty.provider = (data['provider'] or 'manual').strip().lower()
if 'servicelevel' in data:
warranty.servicelevel = (data['servicelevel'] or '').strip() or None
if 'startdate' in data:
warranty.startdate = _parse_date(data['startdate'])
if 'enddate' in data:
warranty.enddate = _parse_date(data['enddate'])
if 'notes' in data:
warranty.notes = (data['notes'] or '').strip() or None
if 'isactive' in data:
warranty.isactive = bool(data['isactive'])
if 'assetids' in data:
_apply_links(warranty, data['assetids'])
db.session.commit()
return success_response(_warranty_payload(warranty), message='Warranty updated')
@warranty_bp.route('/<int:warrantyid>', methods=['DELETE'])
@jwt_required()
@require_permission('warranty.delete')
def delete_warranty(warrantyid):
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
db.session.delete(warranty)
db.session.commit()
return success_response(message='Warranty deleted')
# =============================================================================
# Provider refresh (phase 1: manual only; API providers report not-configured)
# =============================================================================
@warranty_bp.route('/<int:warrantyid>/refresh', methods=['POST'])
@jwt_required()
@require_permission('warranty.edit')
def refresh_warranty(warrantyid):
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
provider = get_provider(warranty.provider)
try:
result = provider.lookup(warranty.servicetag, warranty.vendor)
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,
'This warranty is manual - nothing to refresh.', http_code=400)
if result.get('servicelevel'):
warranty.servicelevel = result['servicelevel']
if result.get('startdate'):
warranty.startdate = _parse_date(result['startdate'])
if result.get('enddate'):
warranty.enddate = _parse_date(result['enddate'])
warranty.lastcheckeddate = datetime.now(timezone.utc).replace(tzinfo=None)
db.session.commit()
return success_response(_warranty_payload(warranty), message='Warranty refreshed')
# =============================================================================
# Bulk Dell sync - auto-detect Dell hardware by service tag and pull coverage
# =============================================================================
@warranty_bp.route('/sync/dell', methods=['POST'])
@jwt_required()
@require_permission('warranty.edit')
def sync_dell():
"""Look up every asset serial (= Dell service tag) that has no warranty yet
and create warranties for the ones Dell recognizes.
Only assets that lack a dated warranty are looked up, so re-running is cheap
and non-Dell serials are filtered out by Dell (they return no coverage).
"""
provider = get_provider('dell')
# ?all=true re-checks assets that already have a dated warranty too.
recheck_all = request.args.get('all', 'false').lower() == 'true'
# Assets already covered by a dated warranty - skipped unless recheck_all.
covered = set()
if not recheck_all:
for link in WarrantyAsset.query.all():
w = db.session.get(Warranty, link.warrantyid)
if w and w.isactive and w.enddate:
covered.add(link.assetid)
# Candidate assets: active, have a serial, not already covered.
candidates = (Asset.query
.filter(Asset.isactive.is_(True),
Asset.serialnumber.isnot(None),
Asset.serialnumber != '')
.all())
by_tag = {}
for asset in candidates:
if asset.assetid in covered:
continue
by_tag.setdefault(asset.serialnumber.strip().upper(), []).append(asset.assetid)
if not by_tag:
return success_response({
'candidates': 0, 'matched': 0, 'created': 0, 'updated': 0,
}, message='No PCs need a warranty lookup.')
try:
results = provider.bulk_lookup(list(by_tag.keys()))
except (ProviderNotConfigured, WarrantyLookupError) as exc:
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
created = updated = matched = 0
now = datetime.now(timezone.utc).replace(tzinfo=None)
for tag, assetids in by_tag.items():
found = results.get(tag)
if not found:
continue
matched += 1
for assetid in assetids:
# Reuse an existing Dell warranty for this asset if there is one.
existing = None
for link in WarrantyAsset.query.filter_by(assetid=assetid).all():
candidate = db.session.get(Warranty, link.warrantyid)
if candidate and candidate.provider == 'dell':
existing = candidate
break
if existing:
existing.servicelevel = found.get('servicelevel')
existing.startdate = _parse_date(found.get('startdate'))
existing.enddate = _parse_date(found.get('enddate'))
existing.servicetag = tag
existing.lastcheckeddate = now
updated += 1
else:
warranty = Warranty(
vendor='Dell', provider='dell', servicetag=tag,
servicelevel=found.get('servicelevel'),
startdate=_parse_date(found.get('startdate')),
enddate=_parse_date(found.get('enddate')),
lastcheckeddate=now,
)
warranty.links.append(WarrantyAsset(assetid=assetid))
db.session.add(warranty)
created += 1
db.session.commit()
return success_response({
'candidates': sum(len(v) for v in by_tag.values()),
'tags': len(by_tag),
'matched': matched,
'created': created,
'updated': updated,
}, message=f'Dell sync: {created} added, {updated} updated, {matched} tags matched.')
# =============================================================================
# Report buckets (for the Reports hub)
# =============================================================================
@warranty_bp.route('/report', methods=['GET'])
@jwt_required(optional=True)
def warranty_report():
"""Counts + lists bucketed by derived status."""
today = date.today()
buckets = {'expired': [], 'expiring': [], 'active': [], 'unknown': []}
for w in Warranty.query.filter_by(isactive=True).all():
buckets.setdefault(w.status(today), []).append(_warranty_payload(w, today))
return success_response({
'counts': {k: len(v) for k, v in buckets.items()},
'buckets': buckets,
})

View File

@@ -0,0 +1,12 @@
{
"name": "warranty",
"version": "1.0.0",
"description": "Asset warranty tracking - manual entry now, Dell/Lenovo/HP provider lookups later. Derived coverage status + report buckets.",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.2.0,<1.0.0",
"api_prefix": "/api/warranty",
"provides": {
"features": ["warranty-tracking"]
}
}

View File

@@ -0,0 +1,14 @@
"""Alembic environment for the warranty plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_warranty. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'warranty'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""warranty plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_warranty. From this
anchor forward, new warranty schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'warranty0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

@@ -0,0 +1,5 @@
"""Warranty plugin models."""
from .warranty import Warranty, WarrantyAsset, derive_status, STATUS_COLORS
__all__ = ['Warranty', 'WarrantyAsset', 'derive_status', 'STATUS_COLORS']

View File

@@ -0,0 +1,96 @@
"""Warranty models.
A Warranty is provider-agnostic (manual entry, or looked up from Dell/Lenovo/HP
later). It links to one or more assets via warrantyassets. Coverage status is
DERIVED from enddate at read time, never stored, so it is always current.
"""
from datetime import date, timedelta
from shopdb.api import db
# Window before enddate where a warranty counts as "expiring soon".
EXPIRING_WINDOW_DAYS = 180
# Derived status -> display color (hex). Reused by the frontend status badge.
STATUS_COLORS = {
'active': '#4CAF50',
'expiring': '#FF9800',
'expired': '#F44336',
'unknown': '#9E9E9E',
}
def derive_status(enddate, today=None):
"""Coverage status from an end date. Never stored - always computed."""
if not enddate:
return 'unknown'
today = today or date.today()
if enddate < today:
return 'expired'
if enddate <= today + timedelta(days=EXPIRING_WINDOW_DAYS):
return 'expiring'
return 'active'
class Warranty(db.Model):
__tablename__ = 'warranties'
warrantyid = db.Column(db.Integer, primary_key=True)
vendor = db.Column(db.String(100), nullable=False)
# Service tag / serial the provider identifies the unit by.
servicetag = db.Column(db.String(100))
# Where the record came from: manual, dell, lenovo, hp.
provider = db.Column(db.String(20), nullable=False, server_default='manual')
servicelevel = db.Column(db.String(150))
startdate = db.Column(db.Date)
enddate = db.Column(db.Date)
# When a provider lookup last refreshed this record.
lastcheckeddate = db.Column(db.DateTime)
notes = db.Column(db.Text)
isactive = db.Column(db.Boolean, nullable=False, server_default='1')
links = db.relationship('WarrantyAsset', back_populates='warranty',
cascade='all, delete-orphan')
def status(self, today=None):
return derive_status(self.enddate, today)
def to_dict(self, today=None):
status = self.status(today)
return {
'warrantyid': self.warrantyid,
'vendor': self.vendor,
'servicetag': self.servicetag,
'provider': self.provider,
'servicelevel': self.servicelevel,
'startdate': self.startdate.isoformat() if self.startdate else None,
'enddate': self.enddate.isoformat() if self.enddate else None,
'lastcheckeddate': self.lastcheckeddate.isoformat() + 'Z' if self.lastcheckeddate else None,
'notes': self.notes,
'isactive': bool(self.isactive),
'status': status,
'statuscolor': STATUS_COLORS.get(status, STATUS_COLORS['unknown']),
}
class WarrantyAsset(db.Model):
__tablename__ = 'warrantyassets'
id = db.Column(db.Integer, primary_key=True)
warrantyid = db.Column(
db.Integer,
db.ForeignKey('warranties.warrantyid', ondelete='CASCADE'),
nullable=False
)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False
)
warranty = db.relationship('Warranty', back_populates='links')
__table_args__ = (
db.UniqueConstraint('warrantyid', 'assetid', name='uq_warrantyasset_warranty_asset'),
)

101
plugins/warranty/plugin.py Normal file
View File

@@ -0,0 +1,101 @@
"""Warranty plugin main class.
Asset-general plugin: owns its warranties + warrantyassets tables, an API
surface, and a sidebar entry. Not tied to any one asset type - a warranty can
cover a PC, printer, network device, or machine.
"""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from .api import warranty_bp
from .models import Warranty, WarrantyAsset
logger = logging.getLogger(__name__)
class WarrantyPlugin(BasePlugin):
"""Warranty tracking plugin."""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists():
with open(manifest_path, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
return PluginMeta(
name=self._manifest.get('name', 'warranty'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get('description', 'Asset warranty tracking'),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=0.2.0,<1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/warranty'),
)
def get_blueprint(self) -> Optional[Blueprint]:
return warranty_bp
def get_models(self) -> List[Type]:
return [Warranty, WarrantyAsset]
def get_navigation_items(self) -> List[Dict]:
return [
{
'name': 'Warranties',
'icon': 'shield',
'route': '/warranties',
'position': 8,
},
]
def get_reports(self) -> List[Dict]:
return [
{
'id': 'warranty',
'name': 'Warranty Report',
'description': 'Assets bucketed by coverage: expired, expiring soon, active',
'category': 'warranty',
'route': '/reports/warranty',
},
]
def get_asset_panels(self) -> List[Dict]:
# ADR-010 pilot. Warranty is asset-general so it shows on every detail
# page (['*']). Declares the existing per-asset warranty endpoint the
# WarrantyPanel already reads; the generic core panel renderer consumes
# this instead of each detail view hand-composing the panel.
return [
{
'id': 'warranty',
'title': 'Warranty',
'assettypes': ['*'],
'endpoint': '/api/warranty/asset/{assetid}',
'render': 'table',
'position': 30,
},
]
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('warranty.view', 'View warranties', 'warranty'),
('warranty.create', 'Create warranties', 'warranty'),
('warranty.edit', 'Edit warranties', 'warranty'),
('warranty.delete', 'Delete warranties', 'warranty'),
]
def init_app(self, app: Flask, db_instance) -> None:
logger.info(f"Warranty plugin initialized (v{self.meta.version})")

View File

@@ -0,0 +1,10 @@
"""Warranty plugin services."""
from .providers import (
get_provider,
provider_names,
ProviderNotConfigured,
WarrantyLookupError,
)
__all__ = ['get_provider', 'provider_names', 'ProviderNotConfigured', 'WarrantyLookupError']

View File

@@ -0,0 +1,269 @@
"""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())