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:
5
plugins/warranty/__init__.py
Normal file
5
plugins/warranty/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Warranty plugin package."""
|
||||
|
||||
from .plugin import WarrantyPlugin
|
||||
|
||||
__all__ = ['WarrantyPlugin']
|
||||
5
plugins/warranty/api/__init__.py
Normal file
5
plugins/warranty/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Warranty plugin API."""
|
||||
|
||||
from .routes import warranty_bp
|
||||
|
||||
__all__ = ['warranty_bp']
|
||||
336
plugins/warranty/api/routes.py
Normal file
336
plugins/warranty/api/routes.py
Normal 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,
|
||||
})
|
||||
12
plugins/warranty/manifest.json
Normal file
12
plugins/warranty/manifest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
14
plugins/warranty/migrations/env.py
Normal file
14
plugins/warranty/migrations/env.py
Normal 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()
|
||||
24
plugins/warranty/migrations/script.py.mako
Normal file
24
plugins/warranty/migrations/script.py.mako
Normal 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"}
|
||||
28
plugins/warranty/migrations/versions/0001_warranty_anchor.py
Normal file
28
plugins/warranty/migrations/versions/0001_warranty_anchor.py
Normal 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
|
||||
5
plugins/warranty/models/__init__.py
Normal file
5
plugins/warranty/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Warranty plugin models."""
|
||||
|
||||
from .warranty import Warranty, WarrantyAsset, derive_status, STATUS_COLORS
|
||||
|
||||
__all__ = ['Warranty', 'WarrantyAsset', 'derive_status', 'STATUS_COLORS']
|
||||
96
plugins/warranty/models/warranty.py
Normal file
96
plugins/warranty/models/warranty.py
Normal 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
101
plugins/warranty/plugin.py
Normal 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})")
|
||||
10
plugins/warranty/services/__init__.py
Normal file
10
plugins/warranty/services/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Warranty plugin services."""
|
||||
|
||||
from .providers import (
|
||||
get_provider,
|
||||
provider_names,
|
||||
ProviderNotConfigured,
|
||||
WarrantyLookupError,
|
||||
)
|
||||
|
||||
__all__ = ['get_provider', 'provider_names', 'ProviderNotConfigured', 'WarrantyLookupError']
|
||||
269
plugins/warranty/services/providers.py
Normal file
269
plugins/warranty/services/providers.py
Normal 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())
|
||||
Reference in New Issue
Block a user