@@ -103,3 +128,39 @@ const {
onMounted(loadSettings)
+
+
diff --git a/frontend/src/views/settings/SiteConfigHub.vue b/frontend/src/views/settings/SiteConfigHub.vue
new file mode 100644
index 00000000..5cb1cdeb
--- /dev/null
+++ b/frontend/src/views/settings/SiteConfigHub.vue
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+ Site & Facility
+
+
+ Floor Map
+
+
+ Branding
+
+
+ Import Site Assets
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/settings/asset-types/SubtypesTab.vue b/frontend/src/views/settings/asset-types/SubtypesTab.vue
new file mode 100644
index 00000000..3341c9ea
--- /dev/null
+++ b/frontend/src/views/settings/asset-types/SubtypesTab.vue
@@ -0,0 +1,303 @@
+
+
+
+
+
+
Loading...
+
+
+
No descriptor found for this asset type.
+
The plugin may not have implemented the get_asset_type_descriptor hook yet.
+
+
+
+
+
+
+
+ | {{ capitalize(descriptor.subtype_label_field) }} |
+ Description |
+ Color |
+ Actions |
+
+
+
+
+ | {{ item[descriptor.subtype_label_field] }} |
+
+ {{ item[descriptor.subtype_description_field] || '-' }}
+ |
+
+
+ {{ item[descriptor.subtype_color_field] }}
+
+ auto
+ |
+
+
+ inactive
+
+
+
+ |
+
+
+ |
+ No {{ typeLabel }} types found
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/settings/asset-types/SuppliesTab.vue b/frontend/src/views/settings/asset-types/SuppliesTab.vue
new file mode 100644
index 00000000..dc740b1c
--- /dev/null
+++ b/frontend/src/views/settings/asset-types/SuppliesTab.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
Placeholder for printer supplies management (printer types specific).
+
This tab proves the optional tabs pattern works - it only appears for printers.
+
+
+
+
+
+
+
diff --git a/frontend/src/views/settings/asset-types/TypesTabWrapper.vue b/frontend/src/views/settings/asset-types/TypesTabWrapper.vue
new file mode 100644
index 00000000..3db15eb4
--- /dev/null
+++ b/frontend/src/views/settings/asset-types/TypesTabWrapper.vue
@@ -0,0 +1,45 @@
+
+
+ Loading...
+
+
+
+
+
diff --git a/frontend/src/views/settings/settingsNav.js b/frontend/src/views/settings/settingsNav.js
index 3b19a95e..6663d267 100644
--- a/frontend/src/views/settings/settingsNav.js
+++ b/frontend/src/views/settings/settingsNav.js
@@ -10,49 +10,31 @@ export const settingsGroups = [
{
title: 'Site & Facility',
cards: [
- { to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, PC access domain, employee-ID pattern, printer hostname template' },
- { to: '/settings/floormap', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint image and pixel dimensions (width, height) for the site map' },
- { to: '/settings/branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and brand primary, accent, sidebar, and theme colors' },
- { to: '/settings/importsiteassets', icon: Upload, title: 'Import Site Assets', description: 'Seed Machines, PCs, and Applications via the Asset Collector script, existing databases, or Intune/Azure exports; set the shared CSV collection location and download collected files' },
+ { to: '/settings/siteconfig', icon: Home, title: 'Site Configuration', description: 'Site identity, facility settings, floor map, branding, and asset imports' },
],
},
{
title: 'General Reference',
cards: [
- { to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' },
- { to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' },
- { to: '/settings/modeltypes', icon: Monitor, title: 'Model Types', description: 'Manage model type categories' },
- { to: '/settings/statuses', icon: Tag, title: 'Statuses', description: 'Manage asset status types' },
- { to: '/settings/relationshiptypes', icon: Link, title: 'Relationship Types', description: 'Manage asset relationship types (Controls, Contains...) + colors' },
- { to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' },
- { to: '/settings/customfields', icon: SlidersHorizontal, title: 'Custom Fields', description: 'Define extra attributes per asset type (shown on detail + forms)' },
+ { to: '/settings/catalog', icon: Package, title: 'Model Catalog', description: 'Manage vendors, model types, equipment models, and operating systems' },
+ { to: '/settings/assetconfig', icon: Cog, title: 'Asset Configuration', description: 'Configure asset statuses, relationship types, custom fields, and identifiers' },
+ { to: '/settings/assettypes', icon: Factory, title: 'Asset Types', description: 'Manage asset type subtypes, colors, and custom fields for all asset categories' },
{ to: '/settings/supportteams', icon: Users, title: 'Support Teams', description: 'Application support teams and their contacts (ServiceNow group links)' },
- // Operating Systems is cross-asset (PCs, machines, measuring tools, network
- // devices all run an OS), so it lives in General Reference, not under PCs.
- { to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates (used by PCs, machines, network devices, and more)' },
],
},
{
title: 'PCs',
cards: [
- { to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors + map colors' },
{ to: '/settings/accessprotocols', icon: Network, title: 'PC Access Protocols', description: 'Remote-access protocols (VNC, RDP, WinRM) and link templates' },
],
},
{
title: 'Printers',
cards: [
- { to: '/settings/printertypes', icon: Printer, title: 'Printer Types', description: 'Manage printer subtypes + map colors' },
{ to: '/settings/modelsupplies', icon: Droplets, title: 'Model Toners and Supplies', description: 'Map toner, drum, and waste part numbers to printer models' },
{ to: '/settings/printerdrivers', icon: Printer, title: 'Printer Drivers', description: 'Named SMB / HTTP links to printer driver packages' },
],
},
- {
- title: 'Machines',
- cards: [
- { to: '/settings/machinetypes', icon: Wrench, title: 'Machine Types', description: 'Manage machine subtypes + map colors' },
- ],
- },
// The "Measuring Tools" cards are contributed by the measuringtools plugin via
// the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog).
// This empty placeholder fixes the group's position among the asset groups
@@ -66,33 +48,25 @@ export const settingsGroups = [
{
title: 'Locations & Organization',
cards: [
- { to: '/settings/employeedirectory', icon: Contact, title: 'Employee Directory', description: 'Manage the self-hosted people directory (add/edit/import); read-only in external HR mode' },
- { to: '/settings/locations', icon: MapPin, title: 'Locations', description: 'Manage physical locations and sites' },
- { to: '/settings/locationtypes', icon: Tag, title: 'Location Types', description: 'Manage location types + colors' },
- { to: '/settings/businessunits', icon: Building, title: 'Business Units', description: 'Manage organizational units' },
+ { to: '/settings/locations', icon: MapPin, title: 'Locations & Organization', description: 'Physical locations, business units, and employee directory' },
],
},
{
title: 'Network',
cards: [
- { to: '/settings/networktypes', icon: Router, title: 'Network Device Types', description: 'Manage network device subtypes + map colors' },
- { to: '/settings/vlans', icon: Globe, title: 'VLANs', description: 'Manage virtual LANs' },
- { to: '/settings/subnets', icon: Link, title: 'Subnets', description: 'Manage IP subnets and DHCP' },
+ { to: '/settings/network', icon: Globe, title: 'Network Infrastructure', description: 'Manage VLANs and IP subnets' },
],
},
{
title: 'Displays & Kiosks',
cards: [
{ to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default location' },
- { to: '/settings/notificationtypes', icon: Bell, title: 'Notification Types', description: 'Manage notification types, display styles, colors, and auto-expiry' },
],
},
{
title: 'Integrations',
cards: [
- { to: '/settings/servicenow', icon: Link, title: 'ServiceNow', description: 'ServiceNow ticket links (incident, change) prefixes and global-search redirect' },
- { to: '/settings/zabbix', icon: Droplets, title: 'Zabbix Supplies', description: 'Zabbix API for real-time printer toner and supply monitoring' },
- { to: '/settings/dellwarranty', icon: ShieldCheck, title: 'Dell Warranty', description: 'Dell TechDirect warranty API lookup by service tag' },
+ { to: '/settings/integrations', icon: Link, title: 'Integrations', description: 'ServiceNow, Zabbix, and Dell Warranty API connections' },
],
},
{
@@ -105,7 +79,6 @@ export const settingsGroups = [
title: 'Search & Identity',
cards: [
{ to: '/settings/globalsearch', icon: Search, title: 'Global Search', description: 'Choose which content types (domains) appear in global search results' },
- { to: '/settings/assetidentifiers', icon: Fingerprint, title: 'Asset Identifiers', description: 'Enable optional asset identifiers per type: gauge lab, maintenance, FQDN hostname' },
{ to: '/settings/printing', icon: Printer, title: 'Printing & Labels', description: 'Printed QR code and USB label targets, barcode style, and URL templates' },
],
},
@@ -119,11 +92,7 @@ export const settingsGroups = [
{
title: 'Access & Security',
cards: [
- { to: '/settings/authentication', icon: KeyRound, title: 'Authentication', description: 'SAML single sign-on (SSO) with your IdP, local login, and auto-create users' },
- { to: '/settings/apitokens', icon: Key, title: 'API Tokens', description: 'Personal access tokens for scripts and integrations (e.g. long-running imports) that outlive login sessions' },
- { to: '/settings/users', icon: Users, title: 'Users & Roles', description: 'Manage user accounts and permissions' },
- { to: '/settings/audit', icon: History, title: 'Audit & Logging', description: 'Audit log retention period and history purge policy' },
- { to: '/settings/auditlogs', icon: FileText, title: 'Audit Logs', description: 'View system activity and change history' },
+ { to: '/settings/security', icon: ShieldCheck, title: 'Access & Security', description: 'Authentication, API tokens, users, roles, and audit logs' },
],
},
]
diff --git a/frontend/src/views/settings/types/NotificationTypesTab.vue b/frontend/src/views/settings/types/NotificationTypesTab.vue
new file mode 100644
index 00000000..99f2db6b
--- /dev/null
+++ b/frontend/src/views/settings/types/NotificationTypesTab.vue
@@ -0,0 +1,286 @@
+
+
+
+
+
+
Loading...
+
+
+
+
+
+
+ | Name |
+ Color |
+ Display Style |
+ Board Order |
+ Active |
+ Actions |
+
+
+
+
+ |
+ {{ item.typename }}
+ {{ item.typedescription }}
+ |
+
+
+ {{ item.typecolor }}
+
+ |
+ {{ item.displaystyle || 'standard' }} |
+ {{ item.boardorder || 100 }} |
+
+
+ {{ item.isactive ? 'yes' : 'no' }}
+
+ |
+
+
+
+ |
+
+
+ |
+ No notification types found
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py
index 86733ee3..dbeaf66b 100644
--- a/plugins/computers/plugin.py
+++ b/plugins/computers/plugin.py
@@ -2307,3 +2307,16 @@ class ComputersPlugin(BasePlugin):
today should weigh it.
"""
return ['computers.view']
+
+ def get_asset_type_descriptor(self):
+ """Return descriptor for computer types management in Asset Type Hub."""
+ return {
+ 'assettype': 'computer',
+ 'subtype_endpoint': '/computers/types',
+ 'subtype_id_field': 'computertypeid',
+ 'subtype_label_field': 'computertype',
+ 'subtype_description_field': 'description',
+ 'subtype_color_field': 'color',
+ 'optional_tabs': [],
+ 'edit_permission': 'computers.manage',
+ }
diff --git a/plugins/employees/frontend/routes.js b/plugins/employees/frontend/routes.js
index c25659be..461c04f7 100644
--- a/plugins/employees/frontend/routes.js
+++ b/plugins/employees/frontend/routes.js
@@ -14,6 +14,12 @@ export default [
name: 'employee-directory',
component: () => import('./views/EmployeeDirectory.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'employees' }
+ },
+ {
+ path: 'settings/locations/employees',
+ name: 'locations-hub-employees',
+ component: () => import('./views/EmployeeDirectory.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, plugin: 'employees' }
}
]
diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py
index 60d6ba52..1ff529f6 100644
--- a/plugins/endpoints/api/routes.py
+++ b/plugins/endpoints/api/routes.py
@@ -18,13 +18,13 @@ from flask import Blueprint, request, current_app
from flask_jwt_extended import current_user
from shopdb.api import (
- db, Asset, Setting, success_response, error_response, paginated_response,
+ db, Asset, AssetRelationship, Setting, success_response, error_response, paginated_response,
ErrorCodes, get_pagination_params, paginate_query, require_permission,
require_role, caller_holds, caller_is_role, mint_service_token,
list_service_tokens,
revoke_service_tokens,
AuditLog, audit_log, collector_authorized, User,
- Application, AppVersion, Script,
+ Application, AppVersion, Script, send_alert,
)
from ..models import (
@@ -34,7 +34,6 @@ from ..models import (
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, minimum_agent_version_for, action_minimum_versions, parse_agent_version, agent_meets_minimum, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
from . import agentstore
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
-from shopdb.api import send_alert
from markupsafe import escape
endpoints_bp = Blueprint('endpoints', __name__)
@@ -3451,22 +3450,10 @@ def _purge_expired_operator_reports():
def _send_operator_report_alert(report, asset_id, hostname):
"""Send email alert for new operator issue report to default alert recipients."""
from flask import url_for, request
- from shopdb.utils.mailer import get_smtp_config, _normalize_recipients
- from shopdb.core.models.relationship import AssetRelationship
current_app.logger.info('_send_operator_report_alert called for report %d, asset %d, hostname %s',
report.operatorreportid, asset_id, hostname)
- # Check if we have recipients before doing expensive work
- config = get_smtp_config()
- current_app.logger.info('SMTP config: enabled=%s, host=%s, alert_recipients=%s',
- config.get('enabled'), config.get('host'), config.get('alert_recipients'))
- recipients = _normalize_recipients(config['alert_recipients'])
- if not recipients:
- current_app.logger.warning('Skipping operator report alert: no alert recipients configured')
- return
- current_app.logger.info('Will send alert to %d recipient(s): %s', len(recipients), recipients)
-
asset = Asset.query.get(asset_id)
if not asset:
current_app.logger.warning('Cannot send operator report alert: asset %d not found', asset_id)
diff --git a/plugins/machines/plugin.py b/plugins/machines/plugin.py
index 7eaec4a7..775e6b20 100644
--- a/plugins/machines/plugin.py
+++ b/plugins/machines/plugin.py
@@ -246,3 +246,16 @@ class MachinesPlugin(BasePlugin):
('machines.edit', 'Edit machines', 'machines'),
('machines.delete', 'Delete machines', 'machines'),
]
+
+ def get_asset_type_descriptor(self):
+ """Return descriptor for machine types management in Asset Type Hub."""
+ return {
+ 'assettype': 'machine',
+ 'subtype_endpoint': '/machines/types',
+ 'subtype_id_field': 'machinetypeid',
+ 'subtype_label_field': 'machinetype',
+ 'subtype_description_field': 'description',
+ 'subtype_color_field': 'color',
+ 'optional_tabs': [],
+ 'edit_permission': 'machines.manage',
+ }
diff --git a/plugins/measuringtools/manifest.json b/plugins/measuringtools/manifest.json
index f2f584af..15489609 100644
--- a/plugins/measuringtools/manifest.json
+++ b/plugins/measuringtools/manifest.json
@@ -7,7 +7,7 @@
"dependencies": [],
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/measuringtools",
- "default_enabled": false,
+ "default_enabled": true,
"provides": {
"features": ["measuring-tools", "calibration-tracking"]
}
diff --git a/plugins/measuringtools/plugin.py b/plugins/measuringtools/plugin.py
index 445a0c80..67dca806 100644
--- a/plugins/measuringtools/plugin.py
+++ b/plugins/measuringtools/plugin.py
@@ -180,3 +180,16 @@ class MeasuringToolsPlugin(BasePlugin):
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
]
+
+ def get_asset_type_descriptor(self):
+ """Return descriptor for measuring tool types management in Asset Type Hub."""
+ return {
+ 'assettype': 'measuring_tool',
+ 'subtype_endpoint': '/measuringtools/types',
+ 'subtype_id_field': 'measuringtooltypeid',
+ 'subtype_label_field': 'name',
+ 'subtype_description_field': 'description',
+ 'subtype_color_field': 'color',
+ 'optional_tabs': [],
+ 'edit_permission': 'measuringtools.manage',
+ }
diff --git a/plugins/network/frontend/routes.js b/plugins/network/frontend/routes.js
index b7aeb49a..abb6c8ce 100644
--- a/plugins/network/frontend/routes.js
+++ b/plugins/network/frontend/routes.js
@@ -55,6 +55,18 @@ export default [
name: 'subnets',
component: () => import('./views/SubnetsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
+ },
+ {
+ path: 'settings/network/vlans',
+ name: 'network-hub-vlans',
+ component: () => import('./views/VLANsList.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
+ },
+ {
+ path: 'settings/network/subnets',
+ name: 'network-hub-subnets',
+ component: () => import('./views/SubnetsList.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
}
]
diff --git a/plugins/network/plugin.py b/plugins/network/plugin.py
index 084e68e0..474be912 100644
--- a/plugins/network/plugin.py
+++ b/plugins/network/plugin.py
@@ -221,3 +221,16 @@ class NetworkPlugin(BasePlugin):
('network.edit', 'Edit network devices', 'network'),
('network.delete', 'Delete network devices', 'network'),
]
+
+ def get_asset_type_descriptor(self):
+ """Return descriptor for network device types management in Asset Type Hub."""
+ return {
+ 'assettype': 'network_device',
+ 'subtype_endpoint': '/network/types',
+ 'subtype_id_field': 'networkdevicetypeid',
+ 'subtype_label_field': 'networkdevicetype',
+ 'subtype_description_field': 'description',
+ 'subtype_color_field': 'color',
+ 'optional_tabs': [],
+ 'edit_permission': 'network.manage',
+ }
diff --git a/plugins/printers/frontend/routes.js b/plugins/printers/frontend/routes.js
index f5428243..98e57b10 100644
--- a/plugins/printers/frontend/routes.js
+++ b/plugins/printers/frontend/routes.js
@@ -26,6 +26,12 @@ export default [
component: () => import('./views/ZabbixSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
},
+ {
+ path: 'settings/integrations/zabbix',
+ name: 'integrations-zabbix',
+ component: () => import('./views/ZabbixSettings.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
+ },
{
path: 'settings/printer-alerts',
name: 'printer-alerts',
@@ -61,7 +67,7 @@ export default [
path: 'settings/modelsupplies',
name: 'model-supplies',
component: () => import('./views/ModelSuppliesList.vue'),
- meta: { requiresAuth: true, plugin: 'printers' }
+ meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
},
{
path: 'settings/printerdrivers',
diff --git a/plugins/printers/frontend/views/ZabbixSettings.vue b/plugins/printers/frontend/views/ZabbixSettings.vue
index fd1a5ca0..cf79726c 100644
--- a/plugins/printers/frontend/views/ZabbixSettings.vue
+++ b/plugins/printers/frontend/views/ZabbixSettings.vue
@@ -1,7 +1,32 @@
+
+
+
+ ServiceNow
+
+
+ Zabbix Supplies
+
+
+ Dell Warranty
+
@@ -88,3 +113,39 @@ const zabbixMessage = computed(() => {
onMounted(loadSettings)
+
+
diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py
index 1d470255..100c2fed 100644
--- a/plugins/printers/plugin.py
+++ b/plugins/printers/plugin.py
@@ -678,3 +678,16 @@ class PrintersPlugin(BasePlugin):
return None
# _computer_by_hostname returns the (Computer, Asset) pair.
return row[1].assetid
+
+ def get_asset_type_descriptor(self):
+ """Return descriptor for printer types management in Asset Type Hub."""
+ return {
+ 'assettype': 'printer',
+ 'subtype_endpoint': '/printers/types',
+ 'subtype_id_field': 'printertypeid',
+ 'subtype_label_field': 'printertype',
+ 'subtype_description_field': 'description',
+ 'subtype_color_field': 'color',
+ 'optional_tabs': [],
+ 'edit_permission': 'printers.manage',
+ }
diff --git a/plugins/warranty/frontend/routes.js b/plugins/warranty/frontend/routes.js
index 54661f4c..1b818757 100644
--- a/plugins/warranty/frontend/routes.js
+++ b/plugins/warranty/frontend/routes.js
@@ -8,6 +8,12 @@ export default [
component: () => import('./views/DellWarrantySettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'warranty' }
},
+ {
+ path: 'settings/integrations/dell',
+ name: 'integrations-dell',
+ component: () => import('./views/DellWarrantySettings.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, plugin: 'warranty' }
+ },
{
path: 'warranties',
name: 'warranties',
diff --git a/plugins/warranty/frontend/views/DellWarrantySettings.vue b/plugins/warranty/frontend/views/DellWarrantySettings.vue
index f58e4437..503a40af 100644
--- a/plugins/warranty/frontend/views/DellWarrantySettings.vue
+++ b/plugins/warranty/frontend/views/DellWarrantySettings.vue
@@ -1,7 +1,32 @@
+
+
+
+ ServiceNow
+
+
+ Zabbix Supplies
+
+
+ Dell Warranty
+
@@ -132,3 +157,39 @@ const dellMessage = computed(() => {
onMounted(loadSettings)
+
+
diff --git a/shopdb/core/api/models.py b/shopdb/core/api/models.py
index 6eb399aa..a3f916fe 100644
--- a/shopdb/core/api/models.py
+++ b/shopdb/core/api/models.py
@@ -2,6 +2,7 @@
import glob
import os
+from urllib.parse import urlparse
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
@@ -39,6 +40,16 @@ def _modelimage_dir():
return os.path.join(current_app.instance_path, 'modelimages')
+def _validate_url_scheme(url):
+ """Validate that URL uses safe scheme (prevents XSS via javascript: URIs)."""
+ if not url:
+ return True
+ parsed = urlparse(url)
+ if parsed.scheme and parsed.scheme not in ['http', 'https']:
+ raise ValueError(f"Invalid URL scheme '{parsed.scheme}'. Only http:// and https:// are allowed.")
+ return True
+
+
@models_bp.route('', methods=['GET'])
@require_permission('assets.view')
def list_models():
@@ -127,6 +138,13 @@ def create_model():
http_code=409
)
+ # Validate URL scheme to prevent XSS
+ try:
+ _validate_url_scheme(data.get('documentationurl'))
+ _validate_url_scheme(data.get('imageurl'))
+ except ValueError as e:
+ return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
+
m = Model(
modelnumber=data['modelnumber'],
vendorid=data.get('vendorid'),
@@ -162,6 +180,15 @@ def update_model(model_id: int):
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
+ # Validate URL schemes to prevent XSS
+ try:
+ if 'documentationurl' in data:
+ _validate_url_scheme(data['documentationurl'])
+ if 'imageurl' in data:
+ _validate_url_scheme(data['imageurl'])
+ except ValueError as e:
+ return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
+
for key in ['modelnumber', 'vendorid', 'modeltypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']:
if key in data:
setattr(m, key, data[key])
diff --git a/shopdb/core/api/pluginui.py b/shopdb/core/api/pluginui.py
index da33e88c..de67eaa2 100644
--- a/shopdb/core/api/pluginui.py
+++ b/shopdb/core/api/pluginui.py
@@ -131,3 +131,104 @@ def asset_presentation():
"""
entries = _collect('get_asset_presentation')
return success_response(entries)
+
+
+@pluginui_bp.route('/asset-type-descriptors', methods=['GET'])
+@require_permission('assets.view')
+def asset_type_descriptors():
+ """Merge enabled plugins' asset-type subtype descriptors.
+
+ The Asset Type Hub uses these to render generic CRUD interfaces for
+ each plugin's subtype table (printertypes, machinetypes, etc.) without
+ importing plugin models or views.
+
+ Each descriptor maps to ONE asset type (from assettypes table) and
+ tells core:
+ - Which API endpoint handles the subtype CRUD
+ - Field names for ID, label, description, color
+ - Required permission
+ - Optional plugin-specific tabs (e.g., 'supplies' for printers)
+
+ Returns: List[Dict] sorted by assettype name
+ """
+ pm = current_app.extensions.get('plugin_manager')
+ if not pm:
+ return success_response([])
+
+ descriptors = []
+ for name, plugin in pm.get_all_plugins().items():
+ if not pm.registry.is_enabled(name):
+ continue
+ try:
+ desc = plugin.get_asset_type_descriptor()
+ if desc is None:
+ continue
+ desc['plugin'] = name
+ descriptors.append(desc)
+ except Exception:
+ if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
+ raise
+ current_app.logger.exception('Plugin %s get_asset_type_descriptor failed', name)
+
+ # Validation: each descriptor must have required keys
+ required_keys = {'assettype', 'subtype_endpoint', 'subtype_id_field',
+ 'subtype_label_field', 'subtype_description_field',
+ 'subtype_color_field', 'edit_permission'}
+ validated = []
+ for desc in descriptors:
+ missing = required_keys - set(desc.keys())
+ if missing:
+ current_app.logger.error(
+ 'Plugin %s descriptor missing keys: %s',
+ desc.get('plugin', 'unknown'), missing
+ )
+ if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
+ raise ValueError(f"Descriptor missing keys: {missing}")
+ continue
+ validated.append(desc)
+
+ validated.sort(key=lambda d: d.get('assettype', ''))
+ return success_response(validated)
+
+
+@pluginui_bp.route('/core-type-descriptors', methods=['GET'])
+@require_permission('assets.view')
+def core_type_descriptors():
+ """Return type descriptors for core-owned non-asset types.
+
+ These are types managed by core (not plugins) that fit the same
+ management pattern as asset subtypes: location types, notification types, etc.
+
+ Returns same descriptor format as asset-type-descriptors, with optional
+ 'custom_component' field for types that need specialized UI.
+ """
+ descriptors = [
+ {
+ 'category': 'location',
+ 'type_name': 'Location Type',
+ 'type_name_plural': 'Location Types',
+ 'subtype_endpoint': '/locations/types',
+ 'subtype_id_field': 'locationtypeid',
+ 'subtype_label_field': 'locationtype',
+ 'subtype_description_field': 'description',
+ 'subtype_color_field': 'color',
+ 'optional_tabs': [],
+ 'edit_permission': 'admin',
+ 'group': 'other',
+ },
+ {
+ 'category': 'notification',
+ 'type_name': 'Notification Type',
+ 'type_name_plural': 'Notification Types',
+ 'subtype_endpoint': '/notifications/types',
+ 'subtype_id_field': 'notificationtypeid',
+ 'subtype_label_field': 'typename',
+ 'subtype_description_field': 'typedescription',
+ 'subtype_color_field': 'typecolor',
+ 'optional_tabs': [],
+ 'edit_permission': 'admin',
+ 'group': 'other',
+ 'custom_component': 'NotificationTypesTab',
+ },
+ ]
+ return success_response(descriptors)
diff --git a/shopdb/core/api/supportteams.py b/shopdb/core/api/supportteams.py
index b383e427..d3127045 100644
--- a/shopdb/core/api/supportteams.py
+++ b/shopdb/core/api/supportteams.py
@@ -1,5 +1,7 @@
"""Support Teams API endpoints - teams and their nested contacts."""
+from urllib.parse import urlparse
+
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
@@ -19,6 +21,16 @@ from shopdb.utils.authz import require_permission
supportteams_bp = Blueprint('supportteams', __name__)
+def _validate_url_scheme(url):
+ """Validate that URL uses safe scheme (prevents XSS via javascript: URIs)."""
+ if not url:
+ return True
+ parsed = urlparse(url)
+ if parsed.scheme and parsed.scheme not in ['http', 'https']:
+ raise ValueError(f"Invalid URL scheme '{parsed.scheme}'. Only http:// and https:// are allowed.")
+ return True
+
+
@supportteams_bp.route('', methods=['GET'])
@require_permission('assets.view')
def list_support_teams():
@@ -62,6 +74,13 @@ def create_support_team():
f"Support team '{data['teamname']}' already exists",
http_code=409)
+ # Validate URL schemes to prevent XSS
+ try:
+ _validate_url_scheme(data.get('teamurl'))
+ _validate_url_scheme(data.get('webhookurl'))
+ except ValueError as e:
+ return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
+
team = SupportTeam(
teamname=data['teamname'],
teamurl=data.get('teamurl'),
@@ -101,6 +120,15 @@ def update_support_team(team_id: int):
f"Support team '{data['teamname']}' already exists",
http_code=409)
+ # Validate URL schemes to prevent XSS
+ try:
+ if 'teamurl' in data:
+ _validate_url_scheme(data['teamurl'])
+ if 'webhookurl' in data:
+ _validate_url_scheme(data['webhookurl'])
+ except ValueError as e:
+ return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
+
for key in ['teamname', 'teamurl', 'webhookurl', 'isactive']:
if key in data:
setattr(team, key, data[key])
diff --git a/shopdb/plugins/base.py b/shopdb/plugins/base.py
index 523d48f6..25d118c4 100644
--- a/shopdb/plugins/base.py
+++ b/shopdb/plugins/base.py
@@ -16,7 +16,8 @@ class PluginMeta:
author: str = ""
dependencies: List[str] = field(default_factory=list)
# Default to the current pre-1.0 contract range; tighten when 1.0 lands
- core_version: str = ">=0.2.0,<1.0.0"
+ # 0.20.0: added get_asset_type_descriptor hook (additive, backward-compatible)
+ core_version: str = ">=0.20.0,<1.0.0"
api_prefix: str = None
def __post_init__(self):
@@ -440,3 +441,42 @@ class BasePlugin(ABC):
skipped; a broken plugin is isolated in prod, re-raised in dev/test.
"""
return []
+
+ def get_asset_type_descriptor(self) -> Optional[Dict]:
+ """Return subtype management descriptor for this plugin's asset type.
+
+ Declares how to CRUD the plugin's subtype table (printertypes,
+ machinetypes, etc.) so core can render a generic CRUD UI without
+ importing the plugin's models or views. Return None if the plugin
+ has no subtype table.
+
+ Contract (all keys required if returning non-None):
+ {
+ 'assettype': str, # matches assettypes.assettype
+ 'subtype_endpoint': str, # API path WITHOUT /api prefix (e.g., '/printers/types')
+ 'subtype_id_field': str, # PK field name (e.g., 'printertypeid')
+ 'subtype_label_field': str, # Display name field (e.g., 'printertype')
+ 'subtype_description_field': str, # Description field
+ 'subtype_color_field': str, # Color field for map markers
+ 'optional_tabs': List[str], # Plugin-specific tabs (e.g., ['supplies'])
+ 'edit_permission': str, # Required permission (e.g., 'printers.manage')
+ }
+
+ The endpoint must support:
+ - GET (list) ?active=true/false for pagination
+ - GET /{id} for single record
+ - POST (create)
+ - PUT /{id} (update)
+ - DELETE /{id}
+
+ Payload fields:
+ - {subtype_label_field}: required string
+ - {subtype_description_field}: optional text
+ - {subtype_color_field}: optional string (CSS color)
+ - isactive: optional boolean (default True)
+
+ Consumed by GET /api/pluginui/asset-type-descriptors. Disabled
+ plugins are skipped; a broken plugin is isolated in prod, re-raised
+ in dev/test.
+ """
+ return None