Add custom fields + warranty plugin, rework settings into two-pane shell
Feature work from the 2026-07 session: Settings IA - Replace the flat 27-card settings hub with a persistent two-pane shell (SettingsLayout.vue): grouped, searchable left rail + content pane. - Nest all settings/* routes under the shell via router post-processing; shared nav catalog in settingsNav.js. Group by asset class (PCs, Printers, Equipment, Network) so per-type settings stop scattering. Custom fields (core) - customfields + customfieldvalues tables (migration 7d14), CRUD API at /api/customfields, per-asset value get/save. - Settings management page + reusable CustomFieldsSection (detail) and CustomFieldsInputs (form) wired into all four asset types. Warranty (new plugin) - plugins/warranty: warranties + warrantyassets (migration 7d15), derived coverage status, provider abstraction (manual now; Dell/Lenovo/HP stubs). - API CRUD + per-asset panel + report buckets; WarrantyPanel on all four detail pages; Warranties management page; Warranty report + Reports card. - Seed warranty.* permissions. Printer drivers - printerdrivers table (migration 7d13) linked to printer models; drivers now surface on the matching printer's detail page. Other - PCDetail rebalanced (Network + Status + Warranty + custom fields on the right). - Rename PCs list "Features" column to "Remote Access"; fix badge hover underline. - Drop equipment islocationonly field. - Centralize asset-type label/route maps into utils/assetTypes.js. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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']
|
||||
228
plugins/warranty/api/routes.py
Normal file
228
plugins/warranty/api/routes.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""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
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import (
|
||||
db, Asset,
|
||||
success_response, error_response, ErrorCodes,
|
||||
require_permission,
|
||||
)
|
||||
|
||||
from ..models import Warranty, WarrantyAsset
|
||||
from ..services import get_provider, ProviderNotConfigured
|
||||
|
||||
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):
|
||||
"""to_dict plus the linked-asset summaries."""
|
||||
data = warranty.to_dict(today)
|
||||
assets = []
|
||||
for link in warranty.links:
|
||||
asset = Asset.query.get(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 Asset.query.get(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)
|
||||
assetid = request.args.get('assetid', type=int)
|
||||
if assetid:
|
||||
query = (query.join(WarrantyAsset, WarrantyAsset.warrantyid == Warranty.warrantyid)
|
||||
.filter(WarrantyAsset.assetid == assetid))
|
||||
warranties = query.order_by(Warranty.enddate.is_(None), Warranty.enddate).all()
|
||||
|
||||
today = date.today()
|
||||
items = [_warranty_payload(w, today) 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 = Warranty.query.get(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 = Warranty.query.get(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 = Warranty.query.get(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 = Warranty.query.get(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 = Warranty.query.get(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 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.utcnow()
|
||||
db.session.commit()
|
||||
return success_response(_warranty_payload(warranty), message='Warranty refreshed')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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"]
|
||||
}
|
||||
}
|
||||
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'),
|
||||
)
|
||||
65
plugins/warranty/plugin.py
Normal file
65
plugins/warranty/plugin.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""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 equipment.
|
||||
"""
|
||||
|
||||
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 init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f"Warranty plugin initialized (v{self.meta.version})")
|
||||
9
plugins/warranty/services/__init__.py
Normal file
9
plugins/warranty/services/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Warranty plugin services."""
|
||||
|
||||
from .providers import (
|
||||
get_provider,
|
||||
provider_names,
|
||||
ProviderNotConfigured,
|
||||
)
|
||||
|
||||
__all__ = ['get_provider', 'provider_names', 'ProviderNotConfigured']
|
||||
93
plugins/warranty/services/providers.py
Normal file
93
plugins/warranty/services/providers.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Warranty provider abstraction.
|
||||
|
||||
A provider looks up coverage for a unit by service tag / serial. Phase 1 ships
|
||||
manual entry plus provider stubs that read per-vendor API config from settings.
|
||||
When a real API (Dell TechDirect, Lenovo, HP) is wired later, only the matching
|
||||
provider's lookup() body changes - callers and the API surface stay the same.
|
||||
"""
|
||||
|
||||
from shopdb.api import db
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
|
||||
class ProviderNotConfigured(Exception):
|
||||
"""Raised when a provider is asked to look up but has no API config."""
|
||||
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
|
||||
|
||||
|
||||
class WarrantyProvider:
|
||||
"""Base provider. name matches Warranty.provider values."""
|
||||
name = 'base'
|
||||
|
||||
def lookup(self, servicetag, vendor=None):
|
||||
"""Return a dict of {servicelevel, startdate, enddate} or None.
|
||||
|
||||
Raises ProviderNotConfigured when the provider needs API creds it does
|
||||
not have.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ManualProvider(WarrantyProvider):
|
||||
"""No external lookup - values are entered by hand."""
|
||||
name = 'manual'
|
||||
|
||||
def lookup(self, servicetag, vendor=None):
|
||||
return None
|
||||
|
||||
|
||||
class ApiProvider(WarrantyProvider):
|
||||
"""Common shape for vendor API providers. Reads enabled/url/token from
|
||||
settings under warranty_<name>_*. Real HTTP call is deferred to a later
|
||||
phase; today it fails loud if asked to look up so manual entry is unaffected.
|
||||
"""
|
||||
|
||||
def _config(self):
|
||||
enabled = str(_setting(f'warranty_{self.name}_enabled', 'false')).lower() == 'true'
|
||||
url = _setting(f'warranty_{self.name}_apiurl')
|
||||
token = _setting(f'warranty_{self.name}_apitoken')
|
||||
return enabled, url, token
|
||||
|
||||
def lookup(self, servicetag, vendor=None):
|
||||
enabled, url, token = self._config()
|
||||
if not (enabled and url and token):
|
||||
raise ProviderNotConfigured(
|
||||
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(
|
||||
f'{self.name} API lookup not implemented yet (config present).'
|
||||
)
|
||||
|
||||
|
||||
class DellProvider(ApiProvider):
|
||||
name = 'dell'
|
||||
|
||||
|
||||
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