The sync route asked Dell about every pending service tag in one request. bulk_lookup sends 100 tags per HTTP call at a 15 second timeout, so a freshly-onboarded site was dozens of sequential calls inside a single web request. A gunicorn worker timeout or a proxy would plausibly kill it first, leaving the user with a failure after minutes and no way to tell what landed. F3 released the pooled connection before the vendor call, which stopped one sync from taking the app down, but did not make the request itself survivable. Extract the sync into a service taking a per-run tag limit. The route now caps at 200 tags - two Dell calls - and returns how many are still pending so the caller can run it again. A flask warranty sync-dell command does the same work from the command line for a large first-run backfill. The chunk is sampled at random rather than taken off a stable order. A serial Dell does not recognize never gains a warranty and so stays pending forever; under a stable order those dead tags would occupy every chunk and the sync would never reach the rest of the fleet. Nothing runs this on a schedule. The operation is used a few times a year, so a cron would idle-poll the warranty tables tens of thousands of times a year for nothing and log a configuration failure on every run at sites without Dell credentials. The two existing sync tests patched get_provider on the route module; the provider is now resolved in the service, so they patch it there.
228 lines
8.8 KiB
Python
228 lines
8.8 KiB
Python
"""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
|
|
|
|
import click
|
|
from flask import Flask, Blueprint
|
|
|
|
from shopdb.plugins.base import BasePlugin, PluginMeta
|
|
|
|
from .api import warranty_bp
|
|
from .models import Warranty, WarrantyAsset
|
|
from .services import CLI_SYNC_LIMIT
|
|
|
|
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',
|
|
'permission': 'warranty.view',
|
|
'position': 9,
|
|
},
|
|
]
|
|
|
|
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',
|
|
'permission': 'warranty.view',
|
|
},
|
|
]
|
|
|
|
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': 'list',
|
|
# Field map: the generic renderer builds each list item from the
|
|
# warranty payload without any warranty-specific frontend code.
|
|
'map': {
|
|
'title': 'vendor',
|
|
'badge': {
|
|
'label': 'status',
|
|
'color': 'statuscolor',
|
|
'labelmap': {
|
|
'active': 'Active',
|
|
'expiring': 'Expiring Soon',
|
|
'expired': 'Expired',
|
|
'unknown': 'Unknown',
|
|
},
|
|
},
|
|
'meta': [
|
|
{'key': 'servicelevel'},
|
|
{'key': 'enddate', 'label': 'Ends', 'format': 'date'},
|
|
{'key': 'servicetag', 'label': 'Tag', 'mono': True},
|
|
],
|
|
},
|
|
'empty': 'No warranty on record.',
|
|
'manage': {
|
|
'to': '/warranties?addfor={assetid}',
|
|
'label': 'Add / manage',
|
|
'emptylabel': 'Add one',
|
|
},
|
|
'position': 30,
|
|
},
|
|
]
|
|
|
|
def get_dashboard_widgets(self) -> List[Dict]:
|
|
"""Dashboard card: warranties running out, and ones already expired.
|
|
|
|
Expired stays listed rather than dropping off. A machine out of
|
|
warranty is a purchasing decision someone still has to make, and
|
|
removing it the day it lapses is how it gets missed entirely.
|
|
"""
|
|
return [
|
|
{
|
|
'id': 'warranty-expiring',
|
|
'viewall': '/reports/warranty',
|
|
'title': 'Warranties expiring',
|
|
'shortname': 'Warranty',
|
|
'endpoint': '/api/warranty/dashboard/expiring',
|
|
'render': 'exceptions',
|
|
'severity': 'info',
|
|
'permission': 'warranty.view',
|
|
'empty': 'hide',
|
|
'position': 50,
|
|
'map': {
|
|
'title': 'hostname',
|
|
'detail': 'state',
|
|
# The bay, which is how the floor identifies the PC. No
|
|
# date: expired or expiring is the whole decision when
|
|
# scanning a board, and the exact day belongs on the report
|
|
# you order from.
|
|
# Bare number, no label. The row is hostname, machine,
|
|
# state - three short facts, and "machine 3015" spends a
|
|
# word saying what the position already says.
|
|
'meta': [{'key': 'machinenumber'}],
|
|
'link': '/assets/{assetid}',
|
|
},
|
|
},
|
|
]
|
|
|
|
def get_settings_defaults(self) -> List[Dict]:
|
|
"""Settings this plugin owns."""
|
|
return [
|
|
{
|
|
'key': 'warranty_expiringdays',
|
|
'value': '90',
|
|
'valuetype': 'integer',
|
|
'category': 'warranty',
|
|
'description': 'Days ahead to list an expiring warranty on the '
|
|
'dashboard. Already-expired warranties are always '
|
|
'listed.',
|
|
},
|
|
]
|
|
|
|
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 get_guest_grantable_permissions(self) -> List[str]:
|
|
"""Coverage dates and vendor for an asset somebody is standing at.
|
|
|
|
Nothing here is a credential and nothing reaches a supplier portal; the
|
|
provider lookups run server-side with keys the caller never sees.
|
|
"""
|
|
return ['warranty.view']
|
|
|
|
def get_cli_commands(self) -> List:
|
|
"""Return CLI commands for this plugin."""
|
|
|
|
@click.group('warranty')
|
|
def warrantycli():
|
|
"""Warranty plugin commands."""
|
|
pass
|
|
|
|
@warrantycli.command('sync-dell')
|
|
@click.option('--limit', default=None, type=int,
|
|
help=f'Tags to look up this run (default {CLI_SYNC_LIMIT}).')
|
|
@click.option('--all', 'recheck_all', is_flag=True,
|
|
help='Re-check assets that already have a dated warranty.')
|
|
def syncdell(limit, recheck_all):
|
|
"""Look up Dell warranties for a bounded chunk of pending assets.
|
|
|
|
Manual counterpart to the Dell sync button, for a large first-run
|
|
backfill where repeated clicking would be tedious. Nothing runs this
|
|
on a schedule. Each run takes one Dell call by default; assets that
|
|
already hold a dated warranty are skipped, so runs are cheap and
|
|
safely repeatable until `remaining` reaches zero.
|
|
"""
|
|
from flask import current_app
|
|
from .services import run_dell_sync, CLI_SYNC_LIMIT as default_limit
|
|
from .services import ProviderNotConfigured, WarrantyLookupError
|
|
|
|
with current_app.app_context():
|
|
try:
|
|
summary = run_dell_sync(limit=limit or default_limit,
|
|
recheck_all=recheck_all)
|
|
except (ProviderNotConfigured, WarrantyLookupError) as exc:
|
|
raise click.ClickException(str(exc))
|
|
click.echo(
|
|
f"Dell sync: {summary['tags']} tag(s) checked, "
|
|
f"{summary['matched']} matched, {summary['created']} added, "
|
|
f"{summary['updated']} updated, {summary['remaining']} remaining.")
|
|
|
|
return [warrantycli]
|
|
|
|
def init_app(self, app: Flask, db_instance) -> None:
|
|
logger.info(f"Warranty plugin initialized (v{self.meta.version})")
|