From a9d0dfff4ad075d934e4cea3b9bb07e7fc22b5d6 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Tue, 14 Jul 2026 07:58:51 -0400 Subject: [PATCH] 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 --- .env.example | 88 + .gitattributes | 1 + .gitignore | 78 + CHANGELOG.md | 476 ++ CONTRIBUTING.md | 162 + Dockerfile | 80 + README.md | 243 + deploy/windows/web.config | 79 + docker-compose.yml | 65 + frontend/DEVELOPMENT-STANDARDS.md | 120 + frontend/index.html | 14 + frontend/package-lock.json | 4554 +++++++++++++++++ frontend/package.json | 35 + frontend/public/favicon.svg | 4 + frontend/public/ge-aerospace-logo.svg | 8 + frontend/public/ge-monogram.svg | 1 + .../images/models/computers/Latitude-5450.png | Bin 0 -> 7425 bytes .../computers/OptiPlex-Tower-Plus-7010.png | Bin 0 -> 16016 bytes .../images/models/computers/Optiplex-5050.png | Bin 0 -> 65084 bytes .../images/models/computers/Optiplex-5060.png | Bin 0 -> 27462 bytes .../models/computers/Optiplex-7000-Plus.png | Bin 0 -> 14205 bytes .../images/models/computers/Optiplex-7000.png | Bin 0 -> 88561 bytes .../images/models/computers/Optiplex-7080.jpg | Bin 0 -> 8778 bytes .../images/models/machines/1000C1000.jpg | Bin 0 -> 82919 bytes .../public/images/models/machines/2SP-V80.png | Bin 0 -> 102903 bytes .../public/images/models/machines/7107sf.png | Bin 0 -> 16362 bytes .../images/models/machines/Cisco9120.png | Bin 0 -> 2060 bytes .../public/images/models/machines/IDF.png | Bin 0 -> 30443 bytes .../public/images/models/machines/M710uc.png | Bin 0 -> 384871 bytes .../public/images/models/machines/M719uc.png | Bin 0 -> 384871 bytes .../images/models/machines/OptiPlex-7070.png | Bin 0 -> 40753 bytes .../images/models/machines/Optiplex-7000.png | Bin 0 -> 88561 bytes .../public/images/models/machines/a81nx.png | Bin 0 -> 342930 bytes .../images/models/machines/abtech-eas1000.png | Bin 0 -> 156153 bytes .../models/machines/abtech-eas1000.webp | Bin 0 -> 13042 bytes .../public/images/models/machines/c4500.png | Bin 0 -> 21992 bytes .../public/images/models/machines/cmm.png | Bin 0 -> 1307480 bytes .../public/images/models/machines/d218.png | Bin 0 -> 263615 bytes .../public/images/models/machines/eddy.png | Bin 0 -> 2082358 bytes .../public/images/models/machines/ezeddy.png | Bin 0 -> 182869 bytes .../public/images/models/machines/furnace.png | Bin 0 -> 262350 bytes .../public/images/models/machines/g750.jpg | Bin 0 -> 16418 bytes .../public/images/models/machines/hbroach.png | Bin 0 -> 54514 bytes .../images/models/machines/keyence-vr3100.png | Bin 0 -> 213096 bytes .../images/models/machines/latitude5440.png | Bin 0 -> 25766 bytes .../images/models/machines/leandrum.jpg | Bin 0 -> 11981 bytes .../public/images/models/machines/loc650.png | Bin 0 -> 46624 bytes .../public/images/models/machines/mx3100.png | Bin 0 -> 11621 bytes .../public/images/models/machines/nt4300.jpg | Bin 0 -> 11856 bytes .../public/images/models/machines/p600s.png | Bin 0 -> 105672 bytes .../images/models/machines/phoenixbroach.png | Bin 0 -> 97018 bytes .../images/models/machines/powerturn.png | Bin 0 -> 522739 bytes .../images/models/machines/precision5560.jpg | Bin 0 -> 21711 bytes .../public/images/models/machines/rb2.png | Bin 0 -> 38420 bytes .../images/models/machines/shotpeen.png | Bin 0 -> 707055 bytes .../images/models/machines/turnburn.png | Bin 0 -> 309076 bytes .../public/images/models/machines/vp9000.jpg | Bin 0 -> 21195 bytes .../images/models/machines/vt5502sp.png | Bin 0 -> 1761389 bytes .../public/images/models/machines/vtm100.png | Bin 0 -> 91363 bytes .../images/models/machines/zoller600.png | Bin 0 -> 78554 bytes .../images/models/printers/AltaLink-C8130.jpg | Bin 0 -> 172583 bytes .../images/models/printers/AltaLink-C8130.png | Bin 0 -> 235542 bytes .../images/models/printers/DTC4500e.png | Bin 0 -> 195727 bytes .../images/models/printers/Epson-C3500.png | Bin 0 -> 68057 bytes .../models/printers/HP-DesignJet-T1700dr.png | Bin 0 -> 93706 bytes .../models/printers/LaserJet -CP2025.png | Bin 0 -> 390588 bytes .../images/models/printers/LaserJet-4001n.png | Bin 0 -> 47524 bytes .../images/models/printers/LaserJet-4250.png | Bin 0 -> 68685 bytes .../models/printers/LaserJet-M254dw.jpg | Bin 0 -> 5501 bytes .../models/printers/LaserJet-M254dw.png | Bin 0 -> 22446 bytes .../models/printers/LaserJet-M255dw.png | Bin 0 -> 66397 bytes .../images/models/printers/LaserJet-M404.png | Bin 0 -> 64591 bytes .../images/models/printers/LaserJet-M406.jpg | Bin 0 -> 4233 bytes .../images/models/printers/LaserJet-M406.png | Bin 0 -> 18100 bytes .../models/printers/LaserJet-M454dn.png | Bin 0 -> 67938 bytes .../images/models/printers/LaserJet-M506.png | Bin 0 -> 4892 bytes .../images/models/printers/LaserJet-M602.png | Bin 0 -> 8148 bytes .../images/models/printers/LaserJet-M607.png | Bin 0 -> 52800 bytes .../models/printers/LaserJet-P3015dn.png | Bin 0 -> 8658 bytes .../models/printers/LaserJet-Pro-M252dw.png | Bin 0 -> 12599 bytes .../models/printers/Laserjet-Pro-M251nw.png | Bin 0 -> 9658 bytes .../images/models/printers/Versalink-B405.jpg | Bin 0 -> 12733 bytes .../images/models/printers/Versalink-B405.png | Bin 0 -> 63529 bytes .../models/printers/Versalink-B7125.png | Bin 0 -> 287459 bytes .../images/models/printers/Versalink-C405.png | Bin 0 -> 357879 bytes .../models/printers/Versalink-C7125.jpg | Bin 0 -> 40283 bytes .../models/printers/Versalink-C7125.png | Bin 0 -> 91200 bytes .../images/models/printers/Xerox-EC8036.jpg | Bin 0 -> 22250 bytes .../images/models/printers/Xerox-EC8036.png | Bin 0 -> 118825 bytes .../public/images/models/printers/zt411.jpg | Bin 0 -> 12851 bytes .../public/images/models/printers/zt411.png | Bin 0 -> 57120 bytes .../static/images/floorplan-placeholder.svg | 11 + frontend/src/App.vue | 6 + frontend/src/api/index.js | 1126 ++++ frontend/src/assets/style.css | 1759 +++++++ .../src/components/AssetRelationships.vue | 793 +++ frontend/src/components/ColorSwatchPicker.vue | 101 + .../src/components/CustomFieldsInputs.vue | 82 + .../src/components/CustomFieldsSection.vue | 48 + frontend/src/components/EmailReportButton.vue | 53 + .../src/components/EmbeddedLocationMap.vue | 141 + frontend/src/components/EmployeeSearch.vue | 251 + .../src/components/LocationMapTooltip.vue | 268 + frontend/src/components/Modal.vue | 145 + frontend/src/components/PaginationBar.vue | 83 + frontend/src/components/ShopFloorMap.vue | 790 +++ frontend/src/components/ToastHost.vue | 75 + frontend/src/components/WarrantyPanel.vue | 81 + frontend/src/composables/enabledPlugins.js | 42 + .../src/composables/identifierSettings.js | 71 + frontend/src/composables/listQuery.js | 102 + frontend/src/composables/mapConfig.js | 69 + frontend/src/composables/settingsCatalog.js | 86 + frontend/src/composables/setupState.js | 28 + frontend/src/composables/systemSettings.js | 359 ++ frontend/src/composables/toast.js | 35 + frontend/src/composables/warrantyBadge.js | 44 + frontend/src/main.js | 21 + frontend/src/router/index.js | 170 + frontend/src/router/routes/applications.js | 27 + frontend/src/router/routes/computers.js | 48 + frontend/src/router/routes/core.js | 285 ++ frontend/src/router/routes/geenforce.js | 30 + frontend/src/router/routes/knowledgebase.js | 29 + frontend/src/router/routes/machines.js | 29 + frontend/src/router/routes/measuringtools.js | 54 + frontend/src/router/routes/network.js | 53 + frontend/src/router/routes/notifications.js | 41 + frontend/src/router/routes/printers.js | 42 + frontend/src/router/routes/usb.js | 29 + frontend/src/router/routes/warranty.js | 17 + frontend/src/stores/auth.js | 99 + frontend/src/stores/theme.js | 39 + frontend/src/utils/apiError.js | 9 + frontend/src/utils/assetTypes.js | 63 + frontend/src/utils/basePath.js | 13 + frontend/src/utils/colorStyle.js | 39 + frontend/src/utils/mapColors.js | 54 + frontend/src/utils/mapPdf.js | 135 + frontend/src/utils/qrTarget.js | 26 + frontend/src/utils/siteSettings.js | 169 + frontend/src/views/AppLayout.vue | 278 + frontend/src/views/CalendarView.vue | 380 ++ frontend/src/views/ChangePassword.vue | 90 + frontend/src/views/Dashboard.vue | 129 + frontend/src/views/Login.vue | 118 + frontend/src/views/MapEditor.vue | 412 ++ frontend/src/views/MapView.vue | 390 ++ frontend/src/views/SearchResults.vue | 479 ++ frontend/src/views/SetupWizard.vue | 458 ++ frontend/src/views/ShopfloorDashboard.vue | 762 +++ frontend/src/views/TVDashboard.vue | 182 + .../views/applications/ApplicationDetail.vue | 366 ++ .../views/applications/ApplicationForm.vue | 279 + .../views/applications/ApplicationsList.vue | 213 + .../src/views/employees/EmployeeDetail.vue | 348 ++ .../views/geenforce/EnforcementReports.vue | 161 + .../src/views/geenforce/GeEnforceLayout.vue | 43 + .../src/views/geenforce/ManifestEditor.vue | 1051 ++++ frontend/src/views/geenforce/entryForm.js | 183 + .../src/views/geenforce/entryForm.spec.js | 321 ++ .../knowledgebase/KnowledgeBaseDetail.vue | 212 + .../views/knowledgebase/KnowledgeBaseForm.vue | 167 + .../views/knowledgebase/KnowledgeBaseList.vue | 263 + frontend/src/views/machines/MachineDetail.vue | 321 ++ frontend/src/views/machines/MachineForm.vue | 706 +++ frontend/src/views/machines/MachinesList.vue | 147 + .../measuringtools/MeasuringToolDetail.vue | 210 + .../measuringtools/MeasuringToolForm.vue | 295 ++ .../measuringtools/MeasuringToolsList.vue | 173 + .../src/views/network/NetworkDeviceDetail.vue | 380 ++ .../src/views/network/NetworkDeviceForm.vue | 552 ++ .../src/views/network/NetworkDevicesList.vue | 347 ++ frontend/src/views/network/NetworkHub.vue | 72 + frontend/src/views/network/SubnetDetail.vue | 88 + frontend/src/views/network/SubnetsBrowse.vue | 75 + .../views/notifications/NotificationForm.vue | 642 +++ .../notifications/NotificationTypesList.vue | 350 ++ .../views/notifications/NotificationsList.vue | 175 + frontend/src/views/pcs/PCDetail.vue | 426 ++ frontend/src/views/pcs/PCForm.vue | 609 +++ frontend/src/views/pcs/PCsList.vue | 211 + frontend/src/views/print/AssetLabel.vue | 425 ++ frontend/src/views/print/AssetLabelBatch.vue | 501 ++ frontend/src/views/print/MachineBadge.vue | 169 + frontend/src/views/print/PrinterQRBatch.vue | 307 ++ frontend/src/views/print/PrinterQRSingle.vue | 187 + frontend/src/views/print/USBLabelBatch.vue | 425 ++ frontend/src/views/print/assetLabel.js | 99 + frontend/src/views/print/qrLogo.js | 62 + frontend/src/views/printers/PrinterDetail.vue | 457 ++ frontend/src/views/printers/PrinterForm.vue | 655 +++ frontend/src/views/printers/PrintersList.vue | 167 + .../src/views/reports/CalibrationReport.vue | 101 + .../views/reports/PCRelationshipsReport.vue | 144 + frontend/src/views/reports/ReportsIndex.vue | 514 ++ frontend/src/views/reports/TonerReport.vue | 304 ++ frontend/src/views/reports/WarrantyReport.vue | 156 + .../views/settings/AccessProtocolsList.vue | 220 + frontend/src/views/settings/ApiTokensList.vue | 551 ++ .../settings/AssetIdentifiersSettings.vue | 58 + .../src/views/settings/AssetTypesList.vue | 120 + frontend/src/views/settings/AuditLogs.vue | 389 ++ frontend/src/views/settings/AuditSettings.vue | 51 + .../views/settings/AuthenticationSettings.vue | 163 + .../src/views/settings/BrandingSettings.vue | 142 + .../src/views/settings/BusinessUnitsList.vue | 191 + .../src/views/settings/CustomFieldsList.vue | 231 + .../views/settings/DashboardDefaultsList.vue | 190 + .../views/settings/DellWarrantySettings.vue | 117 + frontend/src/views/settings/EmailSettings.vue | 216 + .../src/views/settings/EmployeeDirectory.vue | 302 ++ .../src/views/settings/FloorMapSettings.vue | 100 + .../views/settings/GlobalSearchSettings.vue | 45 + .../src/views/settings/LocationTypesList.vue | 145 + frontend/src/views/settings/LocationsList.vue | 388 ++ .../src/views/settings/MachineTypesList.vue | 145 + .../views/settings/MeasuringToolTypesList.vue | 157 + .../src/views/settings/ModelSuppliesList.vue | 438 ++ .../src/views/settings/ModelTypesList.vue | 278 + frontend/src/views/settings/ModelsList.vue | 456 ++ .../src/views/settings/NetworkTypesList.vue | 145 + .../views/settings/OperatingSystemsList.vue | 228 + .../views/settings/PCTypeMappingSettings.vue | 30 + frontend/src/views/settings/PCTypesList.vue | 157 + frontend/src/views/settings/PluginsList.vue | 140 + .../src/views/settings/PrinterDriversList.vue | 186 + .../src/views/settings/PrinterTypesList.vue | 145 + .../src/views/settings/PrintingSettings.vue | 192 + .../views/settings/RelationshipTypesList.vue | 155 + .../src/views/settings/ServiceNowSettings.vue | 105 + frontend/src/views/settings/SettingsIndex.vue | 100 + .../src/views/settings/SettingsLayout.vue | 221 + frontend/src/views/settings/SiteSettings.vue | 209 + frontend/src/views/settings/SlideManager.vue | 209 + frontend/src/views/settings/StatusesList.vue | 319 ++ frontend/src/views/settings/SubnetsList.vue | 567 ++ .../src/views/settings/SupportTeamsList.vue | 345 ++ frontend/src/views/settings/UsersList.vue | 746 +++ frontend/src/views/settings/VLANsList.vue | 377 ++ .../src/views/settings/ZabbixSettings.vue | 90 + frontend/src/views/settings/settingsNav.js | 127 + frontend/src/views/usb/USBDetail.vue | 286 ++ frontend/src/views/usb/USBForm.vue | 149 + frontend/src/views/usb/USBList.vue | 304 ++ frontend/src/views/vendors/VendorsList.vue | 330 ++ .../src/views/warranty/WarrantiesList.vue | 407 ++ frontend/vite.config.js | 29 + frontend/vitest.config.js | 19 + migrations/DATA_MIGRATION_GUIDE.md | 13 + .../FIX_LOCATIONONLY_EQUIPMENT_TYPES.md | 89 + .../MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md | 92 + migrations/PRODUCTION_MIGRATION_GUIDE.md | 165 + migrations/README | 1 + migrations/alembic.ini | 50 + migrations/env.py | 169 + migrations/rename_underscore_columns.sql | 23 + migrations/script.py.mako | 24 + .../versions/68b3947ae14f_baseline_schema.py | 956 ++++ .../versions/7a01_adr001_position_contract.py | 147 + migrations/versions/7b02_gaugelabreference.py | 44 + .../versions/7c01_drop_legacy_machine.py | 47 + .../versions/7c02_application_isrequired.py | 29 + migrations/versions/7c03_locationtypes.py | 56 + .../versions/7c04_fold_plugin_schema.py | 85 + migrations/versions/7d01_dashboarddefaults.py | 37 + .../7d02_widen_notification_employee_cols.py | 37 + .../versions/7d03_notificationtype_expiry.py | 53 + .../versions/7d04_notificationtype_display.py | 43 + .../versions/7d05_pc_access_protocols.py | 76 + migrations/versions/7d06_typecolor_to_hex.py | 46 + migrations/versions/7d07_assettype_color.py | 42 + migrations/versions/7d08_tvslides.py | 36 + migrations/versions/7d09_subtype_colors.py | 31 + .../versions/7d10_relationshiptype_color.py | 26 + .../versions/7d11_locationtype_color.py | 23 + .../7d12_drop_computer_isshopfloor.py | 26 + migrations/versions/7d13_printerdrivers.py | 32 + migrations/versions/7d14_customfields.py | 48 + migrations/versions/7d15_warranties.py | 45 + .../versions/7d16_directoryemployees.py | 31 + migrations/versions/7d17_machines_rename.py | 205 + .../versions/7d18_supportteamcontacts.py | 150 + .../7d19_relationshiptype_directional.py | 70 + .../7d20_relationshiptypepropagations.py | 109 + migrations/versions/7d21_apitokens.py | 61 + migrations/versions/7d22_apitokens_scopes.py | 49 + .../versions/7d23_user_mustchangepassword.py | 50 + .../versions/7d24_customfield_searchable.py | 50 + .../versions/7d25_drop_redundant_indexes.py | 57 + plugins/__init__.py | 1 + plugins/computers/__init__.py | 5 + plugins/computers/api/__init__.py | 5 + plugins/computers/api/routes.py | 894 ++++ plugins/computers/manifest.json | 23 + plugins/computers/migrations/env.py | 14 + plugins/computers/migrations/script.py.mako | 24 + .../versions/0001_computers_anchor.py | 28 + plugins/computers/models/__init__.py | 17 + plugins/computers/models/computer.py | 284 + plugins/computers/pctypemap.py | 68 + plugins/computers/plugin.py | 680 +++ plugins/employees/README.md | 133 + plugins/employees/__init__.py | 5 + plugins/employees/api/__init__.py | 5 + plugins/employees/api/routes.py | 504 ++ plugins/employees/manifest.json | 15 + plugins/employees/migrations/env.py | 14 + plugins/employees/migrations/script.py.mako | 24 + .../versions/0001_employees_anchor.py | 28 + .../versions/0002_employee_photofilename.py | 40 + plugins/employees/models/__init__.py | 5 + .../employees/models/directory_employee.py | 43 + plugins/employees/plugin.py | 98 + plugins/geenforce/__init__.py | 5 + plugins/geenforce/api/__init__.py | 5 + plugins/geenforce/api/routes.py | 712 +++ .../geenforce/client/Install-GEEnforce.ps1 | 180 + .../geenforce/client/Invoke-ShopdbEnforce.ps1 | 103 + .../geenforce/client/ShopdbEnforceClient.psm1 | 164 + plugins/geenforce/filters.py | 144 + plugins/geenforce/importer.py | 117 + plugins/geenforce/manifest.json | 14 + plugins/geenforce/migrations/env.py | 14 + plugins/geenforce/migrations/script.py.mako | 24 + .../versions/0001_geenforce_baseline.py | 241 + plugins/geenforce/models/__init__.py | 45 + plugins/geenforce/models/manifest.py | 353 ++ plugins/geenforce/parity.py | 106 + plugins/geenforce/parityfixtures.json | 20 + plugins/geenforce/plugin.py | 194 + plugins/geenforce/serializer.py | 133 + plugins/geenforce/service.py | 343 ++ plugins/knowledgebase/__init__.py | 5 + plugins/knowledgebase/api/__init__.py | 5 + plugins/knowledgebase/api/routes.py | 224 + plugins/knowledgebase/manifest.json | 13 + plugins/knowledgebase/migrations/env.py | 14 + .../knowledgebase/migrations/script.py.mako | 24 + .../versions/0001_knowledgebase_anchor.py | 28 + plugins/knowledgebase/models/__init__.py | 5 + plugins/knowledgebase/models/knowledgebase.py | 40 + plugins/knowledgebase/plugin.py | 83 + plugins/machines/__init__.py | 5 + plugins/machines/api/__init__.py | 5 + plugins/machines/api/routes.py | 550 ++ plugins/machines/manifest.json | 22 + plugins/machines/migrations/env.py | 14 + plugins/machines/migrations/script.py.mako | 24 + .../versions/0001_machines_anchor.py | 32 + .../versions/0002_rename_from_equipment.py | 168 + plugins/machines/models/__init__.py | 8 + plugins/machines/models/machine.py | 133 + plugins/machines/plugin.py | 228 + plugins/measuringtools/__init__.py | 5 + plugins/measuringtools/api/__init__.py | 5 + plugins/measuringtools/api/routes.py | 395 ++ plugins/measuringtools/manifest.json | 14 + plugins/measuringtools/migrations/env.py | 16 + .../measuringtools/migrations/script.py.mako | 24 + .../versions/0001_measuringtools_baseline.py | 69 + plugins/measuringtools/models/__init__.py | 17 + .../measuringtools/models/measuringtool.py | 133 + plugins/measuringtools/plugin.py | 179 + plugins/network/__init__.py | 5 + plugins/network/api/__init__.py | 5 + plugins/network/api/routes.py | 1029 ++++ plugins/network/manifest.json | 22 + plugins/network/migrations/env.py | 14 + plugins/network/migrations/script.py.mako | 24 + .../versions/0001_network_anchor.py | 28 + plugins/network/models/__init__.py | 11 + plugins/network/models/network_device.py | 120 + plugins/network/models/subnet.py | 145 + plugins/network/plugin.py | 225 + plugins/notifications/__init__.py | 5 + plugins/notifications/api/__init__.py | 5 + plugins/notifications/api/routes.py | 759 +++ plugins/notifications/manifest.json | 12 + plugins/notifications/migrations/env.py | 14 + .../notifications/migrations/script.py.mako | 24 + .../versions/0001_notifications_anchor.py | 28 + .../versions/0002_index_businessunitid.py | 42 + plugins/notifications/models/__init__.py | 5 + plugins/notifications/models/notification.py | 195 + plugins/notifications/plugin.py | 231 + plugins/printers/__init__.py | 5 + plugins/printers/api/__init__.py | 7 + plugins/printers/api/asset_routes.py | 1156 +++++ plugins/printers/manifest.json | 25 + plugins/printers/migrations/env.py | 14 + plugins/printers/migrations/script.py.mako | 24 + .../versions/0001_printers_anchor.py | 28 + plugins/printers/models/__init__.py | 20 + plugins/printers/models/model_supply.py | 56 + plugins/printers/models/printer.py | 123 + plugins/printers/models/printer_driver.py | 33 + plugins/printers/plugin.py | 241 + plugins/printers/schemas/__init__.py | 1 + plugins/printers/services/__init__.py | 19 + plugins/printers/services/seed_supplies.py | 358 ++ plugins/printers/services/supply_parts.py | 107 + plugins/printers/services/zabbix_service.py | 254 + plugins/slides/PLAN-slide-manager.md | 132 + plugins/slides/__init__.py | 5 + plugins/slides/api/__init__.py | 5 + plugins/slides/api/routes.py | 210 + plugins/slides/manifest.json | 12 + plugins/slides/migrations/env.py | 14 + plugins/slides/migrations/script.py.mako | 24 + .../migrations/versions/0001_slides_anchor.py | 28 + plugins/slides/models/__init__.py | 5 + plugins/slides/models/tvslide.py | 42 + plugins/slides/plugin.py | 74 + plugins/usb/README.md | 105 + plugins/usb/__init__.py | 5 + plugins/usb/api/__init__.py | 5 + plugins/usb/api/routes.py | 723 +++ plugins/usb/api/selfhosted.py | 276 + plugins/usb/manifest.json | 11 + plugins/usb/migrations/env.py | 14 + plugins/usb/migrations/script.py.mako | 24 + .../migrations/versions/0001_usb_anchor.py | 28 + .../versions/0002_drop_checkout_machineid.py | 42 + plugins/usb/models/__init__.py | 5 + plugins/usb/models/usb_device.py | 167 + plugins/usb/plugin.py | 120 + plugins/warranty/__init__.py | 5 + plugins/warranty/api/__init__.py | 5 + plugins/warranty/api/routes.py | 336 ++ plugins/warranty/manifest.json | 12 + plugins/warranty/migrations/env.py | 14 + plugins/warranty/migrations/script.py.mako | 24 + .../versions/0001_warranty_anchor.py | 28 + plugins/warranty/models/__init__.py | 5 + plugins/warranty/models/warranty.py | 96 + plugins/warranty/plugin.py | 101 + plugins/warranty/services/__init__.py | 10 + plugins/warranty/services/providers.py | 269 + requirements.in | 32 + requirements.txt | 121 + scripts/check-naming-and-style.sh | 118 + scripts/migration/README.md | 24 + scripts/migration/__init__.py | 1 + scripts/migration/fix_legacy_schema.sql | 88 + scripts/migration/one-offs/README.md | 16 + .../one-offs/add_recertification_type.sql | 21 + scripts/site_imports/__init__.py | 0 scripts/site_imports/wjf/README.md | 48 + scripts/site_imports/wjf/__init__.py | 0 scripts/site_imports/wjf/harness.py | 177 + scripts/site_imports/wjf/run.py | 817 +++ scripts/test-external-plugin.sh | 121 + shopdb/__init__.py | 260 + shopdb/api/__init__.py | 271 + shopdb/cli/__init__.py | 513 ++ shopdb/config.py | 186 + shopdb/core/__init__.py | 1 + shopdb/core/api/__init__.py | 51 + shopdb/core/api/apitokens.py | 171 + shopdb/core/api/applications.py | 462 ++ shopdb/core/api/assets.py | 1170 +++++ shopdb/core/api/auditlogs.py | 196 + shopdb/core/api/auth.py | 258 + shopdb/core/api/businessunits.py | 156 + shopdb/core/api/collector.py | 486 ++ shopdb/core/api/customfields.py | 218 + shopdb/core/api/dashboard.py | 216 + shopdb/core/api/dashboarddefaults.py | 131 + shopdb/core/api/locations.py | 241 + shopdb/core/api/models.py | 262 + shopdb/core/api/modeltypes.py | 164 + shopdb/core/api/operatingsystems.py | 146 + shopdb/core/api/plugins.py | 74 + shopdb/core/api/pluginui.py | 108 + shopdb/core/api/reports.py | 636 +++ shopdb/core/api/search.py | 912 ++++ shopdb/core/api/settings.py | 911 ++++ shopdb/core/api/setup.py | 114 + shopdb/core/api/supportteams.py | 219 + shopdb/core/api/users.py | 389 ++ shopdb/core/api/vendors.py | 149 + shopdb/core/models/__init__.py | 70 + shopdb/core/models/apitoken.py | 128 + shopdb/core/models/application.py | 95 + shopdb/core/models/asset.py | 285 ++ shopdb/core/models/auditlog.py | 160 + shopdb/core/models/base.py | 71 + shopdb/core/models/businessunit.py | 31 + shopdb/core/models/communication.py | 93 + shopdb/core/models/customfield.py | 94 + shopdb/core/models/dashboarddefault.py | 28 + shopdb/core/models/location.py | 91 + shopdb/core/models/model.py | 43 + shopdb/core/models/modeltype.py | 33 + shopdb/core/models/operatingsystem.py | 22 + shopdb/core/models/relationship.py | 166 + shopdb/core/models/setting.py | 102 + shopdb/core/models/supportteam.py | 61 + shopdb/core/models/user.py | 241 + shopdb/core/models/vendor.py | 20 + shopdb/core/schemas/__init__.py | 1 + shopdb/core/services/__init__.py | 1 + shopdb/core/services/dualpath.py | 115 + shopdb/exceptions.py | 70 + shopdb/extensions.py | 28 + shopdb/plugins/__init__.py | 399 ++ shopdb/plugins/alembic_template.py | 166 + shopdb/plugins/base.py | 350 ++ shopdb/plugins/cli.py | 281 + shopdb/plugins/loader.py | 276 + shopdb/plugins/migrations.py | 207 + shopdb/plugins/registry.py | 140 + shopdb/plugins/scaffolder.py | 236 + shopdb/plugins/templates/README.md.tmpl | 43 + shopdb/plugins/templates/__init__.py.tmpl | 5 + shopdb/plugins/templates/api/__init__.py.tmpl | 5 + shopdb/plugins/templates/api/routes.py.tmpl | 45 + .../frontend/frontend-api-snippet.js.tmpl | 35 + .../plugins/templates/frontend/routes.js.tmpl | 35 + .../templates/frontend/views/Detail.vue.tmpl | 149 + .../templates/frontend/views/Form.vue.tmpl | 228 + .../templates/frontend/views/List.vue.tmpl | 142 + shopdb/plugins/templates/manifest.json.tmpl | 9 + .../plugins/templates/models/__init__.py.tmpl | 5 + shopdb/plugins/templates/models/model.py.tmpl | 32 + shopdb/plugins/templates/plugin.py.tmpl | 70 + .../templates/schemas/__init__.py.tmpl | 6 + .../plugins/templates/tests/__init__.py.tmpl | 0 .../templates/tests/test_plugin.py.tmpl | 30 + .../static/images/floorplan-placeholder.svg | 11 + shopdb/utils/__init__.py | 20 + shopdb/utils/apitoken_auth.py | 153 + shopdb/utils/authz.py | 106 + shopdb/utils/cmmc_usb_db.py | 38 + shopdb/utils/employee_db.py | 36 + shopdb/utils/import_mode.py | 111 + shopdb/utils/mailer.py | 259 + shopdb/utils/pagination.py | 43 + shopdb/utils/responses.py | 175 + tests/__init__.py | 1 + tests/conftest.py | 148 + tests/test_api_namespace.py | 270 + tests/test_core/__init__.py | 0 tests/test_core/test_apitokens.py | 419 ++ tests/test_core/test_auth_ratelimit.py | 70 + tests/test_core/test_authz.py | 342 ++ tests/test_core/test_collector_contract.py | 434 ++ tests/test_core/test_collector_printers.py | 199 + tests/test_core/test_dashboard_widgets.py | 38 + tests/test_core/test_dashboarddefaults.py | 99 + tests/test_core/test_email.py | 270 + tests/test_core/test_employees.py | 26 + tests/test_core/test_identifiers.py | 77 + tests/test_core/test_import_mode.py | 209 + tests/test_core/test_knowledgebase.py | 34 + tests/test_core/test_location_code.py | 27 + tests/test_core/test_model_image.py | 149 + tests/test_core/test_permissions_catalog.py | 90 + tests/test_core/test_plugins_enabled.py | 29 + tests/test_core/test_pluginui.py | 144 + .../test_relationship_directional.py | 106 + .../test_relationship_propagation.py | 295 ++ tests/test_core/test_reports_hook.py | 55 + tests/test_core/test_search_customfields.py | 114 + tests/test_core/test_search_disabled.py | 43 + tests/test_core/test_search_integrations.py | 105 + tests/test_core/test_search_settings.py | 44 + tests/test_core/test_setting_model.py | 56 + tests/test_core/test_settings_branding.py | 192 + tests/test_core/test_slides.py | 25 + tests/test_core/test_supportteams.py | 234 + tests/test_plugin_contract.py | 309 ++ tests/test_plugin_lifecycle.py | 165 + tests/test_plugin_loader.py | 261 + tests/test_plugin_migrations.py | 242 + tests/test_plugin_scaffold.py | 189 + tests/test_plugin_scaffold_frontend.py | 252 + tests/test_plugins/__init__.py | 0 tests/test_plugins/test_dualpath_single.py | 163 + tests/test_plugins/test_employee_photo.py | 212 + .../test_plugins/test_geenforce_compliance.py | 166 + tests/test_plugins/test_geenforce_crud.py | 278 + .../test_plugins/test_geenforce_ddl_parity.py | 134 + tests/test_plugins/test_geenforce_export.py | 65 + tests/test_plugins/test_geenforce_manifest.py | 152 + tests/test_plugins/test_geenforce_payload.py | 125 + .../test_plugins/test_geenforce_reporting.py | 183 + tests/test_plugins/test_installed_apps.py | 57 + tests/test_plugins/test_measuringtools.py | 475 ++ .../test_model_image_in_detail.py | 100 + tests/test_plugins/test_modelsupplies.py | 121 + tests/test_plugins/test_network_ipaddress.py | 68 + tests/test_plugins/test_pc_default_printer.py | 69 + .../test_plugins/test_printer_install_list.py | 48 + tests/test_plugins/test_shopfloor_feed.py | 72 + tests/test_security_config.py | 90 + tests/test_smoke.py | 113 + wsgi.py | 45 + 599 files changed, 88557 insertions(+) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 deploy/windows/web.config create mode 100644 docker-compose.yml create mode 100644 frontend/DEVELOPMENT-STANDARDS.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/ge-aerospace-logo.svg create mode 100644 frontend/public/ge-monogram.svg create mode 100644 frontend/public/images/models/computers/Latitude-5450.png create mode 100644 frontend/public/images/models/computers/OptiPlex-Tower-Plus-7010.png create mode 100644 frontend/public/images/models/computers/Optiplex-5050.png create mode 100644 frontend/public/images/models/computers/Optiplex-5060.png create mode 100644 frontend/public/images/models/computers/Optiplex-7000-Plus.png create mode 100644 frontend/public/images/models/computers/Optiplex-7000.png create mode 100644 frontend/public/images/models/computers/Optiplex-7080.jpg create mode 100644 frontend/public/images/models/machines/1000C1000.jpg create mode 100644 frontend/public/images/models/machines/2SP-V80.png create mode 100644 frontend/public/images/models/machines/7107sf.png create mode 100644 frontend/public/images/models/machines/Cisco9120.png create mode 100644 frontend/public/images/models/machines/IDF.png create mode 100644 frontend/public/images/models/machines/M710uc.png create mode 100644 frontend/public/images/models/machines/M719uc.png create mode 100644 frontend/public/images/models/machines/OptiPlex-7070.png create mode 100644 frontend/public/images/models/machines/Optiplex-7000.png create mode 100644 frontend/public/images/models/machines/a81nx.png create mode 100644 frontend/public/images/models/machines/abtech-eas1000.png create mode 100644 frontend/public/images/models/machines/abtech-eas1000.webp create mode 100644 frontend/public/images/models/machines/c4500.png create mode 100644 frontend/public/images/models/machines/cmm.png create mode 100644 frontend/public/images/models/machines/d218.png create mode 100644 frontend/public/images/models/machines/eddy.png create mode 100644 frontend/public/images/models/machines/ezeddy.png create mode 100644 frontend/public/images/models/machines/furnace.png create mode 100644 frontend/public/images/models/machines/g750.jpg create mode 100644 frontend/public/images/models/machines/hbroach.png create mode 100644 frontend/public/images/models/machines/keyence-vr3100.png create mode 100644 frontend/public/images/models/machines/latitude5440.png create mode 100644 frontend/public/images/models/machines/leandrum.jpg create mode 100644 frontend/public/images/models/machines/loc650.png create mode 100644 frontend/public/images/models/machines/mx3100.png create mode 100644 frontend/public/images/models/machines/nt4300.jpg create mode 100644 frontend/public/images/models/machines/p600s.png create mode 100644 frontend/public/images/models/machines/phoenixbroach.png create mode 100644 frontend/public/images/models/machines/powerturn.png create mode 100644 frontend/public/images/models/machines/precision5560.jpg create mode 100644 frontend/public/images/models/machines/rb2.png create mode 100644 frontend/public/images/models/machines/shotpeen.png create mode 100644 frontend/public/images/models/machines/turnburn.png create mode 100644 frontend/public/images/models/machines/vp9000.jpg create mode 100644 frontend/public/images/models/machines/vt5502sp.png create mode 100644 frontend/public/images/models/machines/vtm100.png create mode 100644 frontend/public/images/models/machines/zoller600.png create mode 100644 frontend/public/images/models/printers/AltaLink-C8130.jpg create mode 100644 frontend/public/images/models/printers/AltaLink-C8130.png create mode 100644 frontend/public/images/models/printers/DTC4500e.png create mode 100644 frontend/public/images/models/printers/Epson-C3500.png create mode 100644 frontend/public/images/models/printers/HP-DesignJet-T1700dr.png create mode 100644 frontend/public/images/models/printers/LaserJet -CP2025.png create mode 100644 frontend/public/images/models/printers/LaserJet-4001n.png create mode 100644 frontend/public/images/models/printers/LaserJet-4250.png create mode 100644 frontend/public/images/models/printers/LaserJet-M254dw.jpg create mode 100644 frontend/public/images/models/printers/LaserJet-M254dw.png create mode 100644 frontend/public/images/models/printers/LaserJet-M255dw.png create mode 100644 frontend/public/images/models/printers/LaserJet-M404.png create mode 100644 frontend/public/images/models/printers/LaserJet-M406.jpg create mode 100644 frontend/public/images/models/printers/LaserJet-M406.png create mode 100644 frontend/public/images/models/printers/LaserJet-M454dn.png create mode 100644 frontend/public/images/models/printers/LaserJet-M506.png create mode 100644 frontend/public/images/models/printers/LaserJet-M602.png create mode 100644 frontend/public/images/models/printers/LaserJet-M607.png create mode 100644 frontend/public/images/models/printers/LaserJet-P3015dn.png create mode 100644 frontend/public/images/models/printers/LaserJet-Pro-M252dw.png create mode 100644 frontend/public/images/models/printers/Laserjet-Pro-M251nw.png create mode 100644 frontend/public/images/models/printers/Versalink-B405.jpg create mode 100644 frontend/public/images/models/printers/Versalink-B405.png create mode 100644 frontend/public/images/models/printers/Versalink-B7125.png create mode 100644 frontend/public/images/models/printers/Versalink-C405.png create mode 100644 frontend/public/images/models/printers/Versalink-C7125.jpg create mode 100644 frontend/public/images/models/printers/Versalink-C7125.png create mode 100644 frontend/public/images/models/printers/Xerox-EC8036.jpg create mode 100644 frontend/public/images/models/printers/Xerox-EC8036.png create mode 100644 frontend/public/images/models/printers/zt411.jpg create mode 100644 frontend/public/images/models/printers/zt411.png create mode 100644 frontend/public/static/images/floorplan-placeholder.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/index.js create mode 100644 frontend/src/assets/style.css create mode 100644 frontend/src/components/AssetRelationships.vue create mode 100644 frontend/src/components/ColorSwatchPicker.vue create mode 100644 frontend/src/components/CustomFieldsInputs.vue create mode 100644 frontend/src/components/CustomFieldsSection.vue create mode 100644 frontend/src/components/EmailReportButton.vue create mode 100644 frontend/src/components/EmbeddedLocationMap.vue create mode 100644 frontend/src/components/EmployeeSearch.vue create mode 100644 frontend/src/components/LocationMapTooltip.vue create mode 100644 frontend/src/components/Modal.vue create mode 100644 frontend/src/components/PaginationBar.vue create mode 100644 frontend/src/components/ShopFloorMap.vue create mode 100644 frontend/src/components/ToastHost.vue create mode 100644 frontend/src/components/WarrantyPanel.vue create mode 100644 frontend/src/composables/enabledPlugins.js create mode 100644 frontend/src/composables/identifierSettings.js create mode 100644 frontend/src/composables/listQuery.js create mode 100644 frontend/src/composables/mapConfig.js create mode 100644 frontend/src/composables/settingsCatalog.js create mode 100644 frontend/src/composables/setupState.js create mode 100644 frontend/src/composables/systemSettings.js create mode 100644 frontend/src/composables/toast.js create mode 100644 frontend/src/composables/warrantyBadge.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/router/routes/applications.js create mode 100644 frontend/src/router/routes/computers.js create mode 100644 frontend/src/router/routes/core.js create mode 100644 frontend/src/router/routes/geenforce.js create mode 100644 frontend/src/router/routes/knowledgebase.js create mode 100644 frontend/src/router/routes/machines.js create mode 100644 frontend/src/router/routes/measuringtools.js create mode 100644 frontend/src/router/routes/network.js create mode 100644 frontend/src/router/routes/notifications.js create mode 100644 frontend/src/router/routes/printers.js create mode 100644 frontend/src/router/routes/usb.js create mode 100644 frontend/src/router/routes/warranty.js create mode 100644 frontend/src/stores/auth.js create mode 100644 frontend/src/stores/theme.js create mode 100644 frontend/src/utils/apiError.js create mode 100644 frontend/src/utils/assetTypes.js create mode 100644 frontend/src/utils/basePath.js create mode 100644 frontend/src/utils/colorStyle.js create mode 100644 frontend/src/utils/mapColors.js create mode 100644 frontend/src/utils/mapPdf.js create mode 100644 frontend/src/utils/qrTarget.js create mode 100644 frontend/src/utils/siteSettings.js create mode 100644 frontend/src/views/AppLayout.vue create mode 100644 frontend/src/views/CalendarView.vue create mode 100644 frontend/src/views/ChangePassword.vue create mode 100644 frontend/src/views/Dashboard.vue create mode 100644 frontend/src/views/Login.vue create mode 100644 frontend/src/views/MapEditor.vue create mode 100644 frontend/src/views/MapView.vue create mode 100644 frontend/src/views/SearchResults.vue create mode 100644 frontend/src/views/SetupWizard.vue create mode 100644 frontend/src/views/ShopfloorDashboard.vue create mode 100644 frontend/src/views/TVDashboard.vue create mode 100644 frontend/src/views/applications/ApplicationDetail.vue create mode 100644 frontend/src/views/applications/ApplicationForm.vue create mode 100644 frontend/src/views/applications/ApplicationsList.vue create mode 100644 frontend/src/views/employees/EmployeeDetail.vue create mode 100644 frontend/src/views/geenforce/EnforcementReports.vue create mode 100644 frontend/src/views/geenforce/GeEnforceLayout.vue create mode 100644 frontend/src/views/geenforce/ManifestEditor.vue create mode 100644 frontend/src/views/geenforce/entryForm.js create mode 100644 frontend/src/views/geenforce/entryForm.spec.js create mode 100644 frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue create mode 100644 frontend/src/views/knowledgebase/KnowledgeBaseForm.vue create mode 100644 frontend/src/views/knowledgebase/KnowledgeBaseList.vue create mode 100644 frontend/src/views/machines/MachineDetail.vue create mode 100644 frontend/src/views/machines/MachineForm.vue create mode 100644 frontend/src/views/machines/MachinesList.vue create mode 100644 frontend/src/views/measuringtools/MeasuringToolDetail.vue create mode 100644 frontend/src/views/measuringtools/MeasuringToolForm.vue create mode 100644 frontend/src/views/measuringtools/MeasuringToolsList.vue create mode 100644 frontend/src/views/network/NetworkDeviceDetail.vue create mode 100644 frontend/src/views/network/NetworkDeviceForm.vue create mode 100644 frontend/src/views/network/NetworkDevicesList.vue create mode 100644 frontend/src/views/network/NetworkHub.vue create mode 100644 frontend/src/views/network/SubnetDetail.vue create mode 100644 frontend/src/views/network/SubnetsBrowse.vue create mode 100644 frontend/src/views/notifications/NotificationForm.vue create mode 100644 frontend/src/views/notifications/NotificationTypesList.vue create mode 100644 frontend/src/views/notifications/NotificationsList.vue create mode 100644 frontend/src/views/pcs/PCDetail.vue create mode 100644 frontend/src/views/pcs/PCForm.vue create mode 100644 frontend/src/views/pcs/PCsList.vue create mode 100644 frontend/src/views/print/AssetLabel.vue create mode 100644 frontend/src/views/print/AssetLabelBatch.vue create mode 100644 frontend/src/views/print/MachineBadge.vue create mode 100644 frontend/src/views/print/PrinterQRBatch.vue create mode 100644 frontend/src/views/print/PrinterQRSingle.vue create mode 100644 frontend/src/views/print/USBLabelBatch.vue create mode 100644 frontend/src/views/print/assetLabel.js create mode 100644 frontend/src/views/print/qrLogo.js create mode 100644 frontend/src/views/printers/PrinterDetail.vue create mode 100644 frontend/src/views/printers/PrinterForm.vue create mode 100644 frontend/src/views/printers/PrintersList.vue create mode 100644 frontend/src/views/reports/CalibrationReport.vue create mode 100644 frontend/src/views/reports/PCRelationshipsReport.vue create mode 100644 frontend/src/views/reports/ReportsIndex.vue create mode 100644 frontend/src/views/reports/TonerReport.vue create mode 100644 frontend/src/views/reports/WarrantyReport.vue create mode 100644 frontend/src/views/settings/AccessProtocolsList.vue create mode 100644 frontend/src/views/settings/ApiTokensList.vue create mode 100644 frontend/src/views/settings/AssetIdentifiersSettings.vue create mode 100644 frontend/src/views/settings/AssetTypesList.vue create mode 100644 frontend/src/views/settings/AuditLogs.vue create mode 100644 frontend/src/views/settings/AuditSettings.vue create mode 100644 frontend/src/views/settings/AuthenticationSettings.vue create mode 100644 frontend/src/views/settings/BrandingSettings.vue create mode 100644 frontend/src/views/settings/BusinessUnitsList.vue create mode 100644 frontend/src/views/settings/CustomFieldsList.vue create mode 100644 frontend/src/views/settings/DashboardDefaultsList.vue create mode 100644 frontend/src/views/settings/DellWarrantySettings.vue create mode 100644 frontend/src/views/settings/EmailSettings.vue create mode 100644 frontend/src/views/settings/EmployeeDirectory.vue create mode 100644 frontend/src/views/settings/FloorMapSettings.vue create mode 100644 frontend/src/views/settings/GlobalSearchSettings.vue create mode 100644 frontend/src/views/settings/LocationTypesList.vue create mode 100644 frontend/src/views/settings/LocationsList.vue create mode 100644 frontend/src/views/settings/MachineTypesList.vue create mode 100644 frontend/src/views/settings/MeasuringToolTypesList.vue create mode 100644 frontend/src/views/settings/ModelSuppliesList.vue create mode 100644 frontend/src/views/settings/ModelTypesList.vue create mode 100644 frontend/src/views/settings/ModelsList.vue create mode 100644 frontend/src/views/settings/NetworkTypesList.vue create mode 100644 frontend/src/views/settings/OperatingSystemsList.vue create mode 100644 frontend/src/views/settings/PCTypeMappingSettings.vue create mode 100644 frontend/src/views/settings/PCTypesList.vue create mode 100644 frontend/src/views/settings/PluginsList.vue create mode 100644 frontend/src/views/settings/PrinterDriversList.vue create mode 100644 frontend/src/views/settings/PrinterTypesList.vue create mode 100644 frontend/src/views/settings/PrintingSettings.vue create mode 100644 frontend/src/views/settings/RelationshipTypesList.vue create mode 100644 frontend/src/views/settings/ServiceNowSettings.vue create mode 100644 frontend/src/views/settings/SettingsIndex.vue create mode 100644 frontend/src/views/settings/SettingsLayout.vue create mode 100644 frontend/src/views/settings/SiteSettings.vue create mode 100644 frontend/src/views/settings/SlideManager.vue create mode 100644 frontend/src/views/settings/StatusesList.vue create mode 100644 frontend/src/views/settings/SubnetsList.vue create mode 100644 frontend/src/views/settings/SupportTeamsList.vue create mode 100644 frontend/src/views/settings/UsersList.vue create mode 100644 frontend/src/views/settings/VLANsList.vue create mode 100644 frontend/src/views/settings/ZabbixSettings.vue create mode 100644 frontend/src/views/settings/settingsNav.js create mode 100644 frontend/src/views/usb/USBDetail.vue create mode 100644 frontend/src/views/usb/USBForm.vue create mode 100644 frontend/src/views/usb/USBList.vue create mode 100644 frontend/src/views/vendors/VendorsList.vue create mode 100644 frontend/src/views/warranty/WarrantiesList.vue create mode 100644 frontend/vite.config.js create mode 100644 frontend/vitest.config.js create mode 100644 migrations/DATA_MIGRATION_GUIDE.md create mode 100644 migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md create mode 100644 migrations/MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md create mode 100644 migrations/PRODUCTION_MIGRATION_GUIDE.md create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/rename_underscore_columns.sql create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/68b3947ae14f_baseline_schema.py create mode 100644 migrations/versions/7a01_adr001_position_contract.py create mode 100644 migrations/versions/7b02_gaugelabreference.py create mode 100644 migrations/versions/7c01_drop_legacy_machine.py create mode 100644 migrations/versions/7c02_application_isrequired.py create mode 100644 migrations/versions/7c03_locationtypes.py create mode 100644 migrations/versions/7c04_fold_plugin_schema.py create mode 100644 migrations/versions/7d01_dashboarddefaults.py create mode 100644 migrations/versions/7d02_widen_notification_employee_cols.py create mode 100644 migrations/versions/7d03_notificationtype_expiry.py create mode 100644 migrations/versions/7d04_notificationtype_display.py create mode 100644 migrations/versions/7d05_pc_access_protocols.py create mode 100644 migrations/versions/7d06_typecolor_to_hex.py create mode 100644 migrations/versions/7d07_assettype_color.py create mode 100644 migrations/versions/7d08_tvslides.py create mode 100644 migrations/versions/7d09_subtype_colors.py create mode 100644 migrations/versions/7d10_relationshiptype_color.py create mode 100644 migrations/versions/7d11_locationtype_color.py create mode 100644 migrations/versions/7d12_drop_computer_isshopfloor.py create mode 100644 migrations/versions/7d13_printerdrivers.py create mode 100644 migrations/versions/7d14_customfields.py create mode 100644 migrations/versions/7d15_warranties.py create mode 100644 migrations/versions/7d16_directoryemployees.py create mode 100644 migrations/versions/7d17_machines_rename.py create mode 100644 migrations/versions/7d18_supportteamcontacts.py create mode 100644 migrations/versions/7d19_relationshiptype_directional.py create mode 100644 migrations/versions/7d20_relationshiptypepropagations.py create mode 100644 migrations/versions/7d21_apitokens.py create mode 100644 migrations/versions/7d22_apitokens_scopes.py create mode 100644 migrations/versions/7d23_user_mustchangepassword.py create mode 100644 migrations/versions/7d24_customfield_searchable.py create mode 100644 migrations/versions/7d25_drop_redundant_indexes.py create mode 100644 plugins/__init__.py create mode 100644 plugins/computers/__init__.py create mode 100644 plugins/computers/api/__init__.py create mode 100644 plugins/computers/api/routes.py create mode 100644 plugins/computers/manifest.json create mode 100644 plugins/computers/migrations/env.py create mode 100644 plugins/computers/migrations/script.py.mako create mode 100644 plugins/computers/migrations/versions/0001_computers_anchor.py create mode 100644 plugins/computers/models/__init__.py create mode 100644 plugins/computers/models/computer.py create mode 100644 plugins/computers/pctypemap.py create mode 100644 plugins/computers/plugin.py create mode 100644 plugins/employees/README.md create mode 100644 plugins/employees/__init__.py create mode 100644 plugins/employees/api/__init__.py create mode 100644 plugins/employees/api/routes.py create mode 100644 plugins/employees/manifest.json create mode 100644 plugins/employees/migrations/env.py create mode 100644 plugins/employees/migrations/script.py.mako create mode 100644 plugins/employees/migrations/versions/0001_employees_anchor.py create mode 100644 plugins/employees/migrations/versions/0002_employee_photofilename.py create mode 100644 plugins/employees/models/__init__.py create mode 100644 plugins/employees/models/directory_employee.py create mode 100644 plugins/employees/plugin.py create mode 100644 plugins/geenforce/__init__.py create mode 100644 plugins/geenforce/api/__init__.py create mode 100644 plugins/geenforce/api/routes.py create mode 100644 plugins/geenforce/client/Install-GEEnforce.ps1 create mode 100644 plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 create mode 100644 plugins/geenforce/client/ShopdbEnforceClient.psm1 create mode 100644 plugins/geenforce/filters.py create mode 100644 plugins/geenforce/importer.py create mode 100644 plugins/geenforce/manifest.json create mode 100644 plugins/geenforce/migrations/env.py create mode 100644 plugins/geenforce/migrations/script.py.mako create mode 100644 plugins/geenforce/migrations/versions/0001_geenforce_baseline.py create mode 100644 plugins/geenforce/models/__init__.py create mode 100644 plugins/geenforce/models/manifest.py create mode 100644 plugins/geenforce/parity.py create mode 100644 plugins/geenforce/parityfixtures.json create mode 100644 plugins/geenforce/plugin.py create mode 100644 plugins/geenforce/serializer.py create mode 100644 plugins/geenforce/service.py create mode 100644 plugins/knowledgebase/__init__.py create mode 100644 plugins/knowledgebase/api/__init__.py create mode 100644 plugins/knowledgebase/api/routes.py create mode 100644 plugins/knowledgebase/manifest.json create mode 100644 plugins/knowledgebase/migrations/env.py create mode 100644 plugins/knowledgebase/migrations/script.py.mako create mode 100644 plugins/knowledgebase/migrations/versions/0001_knowledgebase_anchor.py create mode 100644 plugins/knowledgebase/models/__init__.py create mode 100644 plugins/knowledgebase/models/knowledgebase.py create mode 100644 plugins/knowledgebase/plugin.py create mode 100644 plugins/machines/__init__.py create mode 100644 plugins/machines/api/__init__.py create mode 100644 plugins/machines/api/routes.py create mode 100644 plugins/machines/manifest.json create mode 100644 plugins/machines/migrations/env.py create mode 100644 plugins/machines/migrations/script.py.mako create mode 100644 plugins/machines/migrations/versions/0001_machines_anchor.py create mode 100644 plugins/machines/migrations/versions/0002_rename_from_equipment.py create mode 100644 plugins/machines/models/__init__.py create mode 100644 plugins/machines/models/machine.py create mode 100644 plugins/machines/plugin.py create mode 100644 plugins/measuringtools/__init__.py create mode 100644 plugins/measuringtools/api/__init__.py create mode 100644 plugins/measuringtools/api/routes.py create mode 100644 plugins/measuringtools/manifest.json create mode 100644 plugins/measuringtools/migrations/env.py create mode 100644 plugins/measuringtools/migrations/script.py.mako create mode 100644 plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py create mode 100644 plugins/measuringtools/models/__init__.py create mode 100644 plugins/measuringtools/models/measuringtool.py create mode 100644 plugins/measuringtools/plugin.py create mode 100644 plugins/network/__init__.py create mode 100644 plugins/network/api/__init__.py create mode 100644 plugins/network/api/routes.py create mode 100644 plugins/network/manifest.json create mode 100644 plugins/network/migrations/env.py create mode 100644 plugins/network/migrations/script.py.mako create mode 100644 plugins/network/migrations/versions/0001_network_anchor.py create mode 100644 plugins/network/models/__init__.py create mode 100644 plugins/network/models/network_device.py create mode 100644 plugins/network/models/subnet.py create mode 100644 plugins/network/plugin.py create mode 100644 plugins/notifications/__init__.py create mode 100644 plugins/notifications/api/__init__.py create mode 100644 plugins/notifications/api/routes.py create mode 100644 plugins/notifications/manifest.json create mode 100644 plugins/notifications/migrations/env.py create mode 100644 plugins/notifications/migrations/script.py.mako create mode 100644 plugins/notifications/migrations/versions/0001_notifications_anchor.py create mode 100644 plugins/notifications/migrations/versions/0002_index_businessunitid.py create mode 100644 plugins/notifications/models/__init__.py create mode 100644 plugins/notifications/models/notification.py create mode 100644 plugins/notifications/plugin.py create mode 100644 plugins/printers/__init__.py create mode 100644 plugins/printers/api/__init__.py create mode 100644 plugins/printers/api/asset_routes.py create mode 100644 plugins/printers/manifest.json create mode 100644 plugins/printers/migrations/env.py create mode 100644 plugins/printers/migrations/script.py.mako create mode 100644 plugins/printers/migrations/versions/0001_printers_anchor.py create mode 100644 plugins/printers/models/__init__.py create mode 100644 plugins/printers/models/model_supply.py create mode 100644 plugins/printers/models/printer.py create mode 100644 plugins/printers/models/printer_driver.py create mode 100644 plugins/printers/plugin.py create mode 100644 plugins/printers/schemas/__init__.py create mode 100644 plugins/printers/services/__init__.py create mode 100644 plugins/printers/services/seed_supplies.py create mode 100644 plugins/printers/services/supply_parts.py create mode 100644 plugins/printers/services/zabbix_service.py create mode 100644 plugins/slides/PLAN-slide-manager.md create mode 100644 plugins/slides/__init__.py create mode 100644 plugins/slides/api/__init__.py create mode 100644 plugins/slides/api/routes.py create mode 100644 plugins/slides/manifest.json create mode 100644 plugins/slides/migrations/env.py create mode 100644 plugins/slides/migrations/script.py.mako create mode 100644 plugins/slides/migrations/versions/0001_slides_anchor.py create mode 100644 plugins/slides/models/__init__.py create mode 100644 plugins/slides/models/tvslide.py create mode 100644 plugins/slides/plugin.py create mode 100644 plugins/usb/README.md create mode 100644 plugins/usb/__init__.py create mode 100644 plugins/usb/api/__init__.py create mode 100644 plugins/usb/api/routes.py create mode 100644 plugins/usb/api/selfhosted.py create mode 100644 plugins/usb/manifest.json create mode 100644 plugins/usb/migrations/env.py create mode 100644 plugins/usb/migrations/script.py.mako create mode 100644 plugins/usb/migrations/versions/0001_usb_anchor.py create mode 100644 plugins/usb/migrations/versions/0002_drop_checkout_machineid.py create mode 100644 plugins/usb/models/__init__.py create mode 100644 plugins/usb/models/usb_device.py create mode 100644 plugins/usb/plugin.py create mode 100644 plugins/warranty/__init__.py create mode 100644 plugins/warranty/api/__init__.py create mode 100644 plugins/warranty/api/routes.py create mode 100644 plugins/warranty/manifest.json create mode 100644 plugins/warranty/migrations/env.py create mode 100644 plugins/warranty/migrations/script.py.mako create mode 100644 plugins/warranty/migrations/versions/0001_warranty_anchor.py create mode 100644 plugins/warranty/models/__init__.py create mode 100644 plugins/warranty/models/warranty.py create mode 100644 plugins/warranty/plugin.py create mode 100644 plugins/warranty/services/__init__.py create mode 100644 plugins/warranty/services/providers.py create mode 100644 requirements.in create mode 100644 requirements.txt create mode 100755 scripts/check-naming-and-style.sh create mode 100644 scripts/migration/README.md create mode 100644 scripts/migration/__init__.py create mode 100644 scripts/migration/fix_legacy_schema.sql create mode 100644 scripts/migration/one-offs/README.md create mode 100644 scripts/migration/one-offs/add_recertification_type.sql create mode 100644 scripts/site_imports/__init__.py create mode 100644 scripts/site_imports/wjf/README.md create mode 100644 scripts/site_imports/wjf/__init__.py create mode 100644 scripts/site_imports/wjf/harness.py create mode 100644 scripts/site_imports/wjf/run.py create mode 100755 scripts/test-external-plugin.sh create mode 100644 shopdb/__init__.py create mode 100644 shopdb/api/__init__.py create mode 100644 shopdb/cli/__init__.py create mode 100644 shopdb/config.py create mode 100644 shopdb/core/__init__.py create mode 100644 shopdb/core/api/__init__.py create mode 100644 shopdb/core/api/apitokens.py create mode 100644 shopdb/core/api/applications.py create mode 100644 shopdb/core/api/assets.py create mode 100644 shopdb/core/api/auditlogs.py create mode 100644 shopdb/core/api/auth.py create mode 100644 shopdb/core/api/businessunits.py create mode 100644 shopdb/core/api/collector.py create mode 100644 shopdb/core/api/customfields.py create mode 100644 shopdb/core/api/dashboard.py create mode 100644 shopdb/core/api/dashboarddefaults.py create mode 100644 shopdb/core/api/locations.py create mode 100644 shopdb/core/api/models.py create mode 100644 shopdb/core/api/modeltypes.py create mode 100644 shopdb/core/api/operatingsystems.py create mode 100644 shopdb/core/api/plugins.py create mode 100644 shopdb/core/api/pluginui.py create mode 100644 shopdb/core/api/reports.py create mode 100644 shopdb/core/api/search.py create mode 100644 shopdb/core/api/settings.py create mode 100644 shopdb/core/api/setup.py create mode 100644 shopdb/core/api/supportteams.py create mode 100644 shopdb/core/api/users.py create mode 100644 shopdb/core/api/vendors.py create mode 100644 shopdb/core/models/__init__.py create mode 100644 shopdb/core/models/apitoken.py create mode 100644 shopdb/core/models/application.py create mode 100644 shopdb/core/models/asset.py create mode 100644 shopdb/core/models/auditlog.py create mode 100644 shopdb/core/models/base.py create mode 100644 shopdb/core/models/businessunit.py create mode 100644 shopdb/core/models/communication.py create mode 100644 shopdb/core/models/customfield.py create mode 100644 shopdb/core/models/dashboarddefault.py create mode 100644 shopdb/core/models/location.py create mode 100644 shopdb/core/models/model.py create mode 100644 shopdb/core/models/modeltype.py create mode 100644 shopdb/core/models/operatingsystem.py create mode 100644 shopdb/core/models/relationship.py create mode 100644 shopdb/core/models/setting.py create mode 100644 shopdb/core/models/supportteam.py create mode 100644 shopdb/core/models/user.py create mode 100644 shopdb/core/models/vendor.py create mode 100644 shopdb/core/schemas/__init__.py create mode 100644 shopdb/core/services/__init__.py create mode 100644 shopdb/core/services/dualpath.py create mode 100644 shopdb/exceptions.py create mode 100644 shopdb/extensions.py create mode 100644 shopdb/plugins/__init__.py create mode 100644 shopdb/plugins/alembic_template.py create mode 100644 shopdb/plugins/base.py create mode 100644 shopdb/plugins/cli.py create mode 100644 shopdb/plugins/loader.py create mode 100644 shopdb/plugins/migrations.py create mode 100644 shopdb/plugins/registry.py create mode 100644 shopdb/plugins/scaffolder.py create mode 100644 shopdb/plugins/templates/README.md.tmpl create mode 100644 shopdb/plugins/templates/__init__.py.tmpl create mode 100644 shopdb/plugins/templates/api/__init__.py.tmpl create mode 100644 shopdb/plugins/templates/api/routes.py.tmpl create mode 100644 shopdb/plugins/templates/frontend/frontend-api-snippet.js.tmpl create mode 100644 shopdb/plugins/templates/frontend/routes.js.tmpl create mode 100644 shopdb/plugins/templates/frontend/views/Detail.vue.tmpl create mode 100644 shopdb/plugins/templates/frontend/views/Form.vue.tmpl create mode 100644 shopdb/plugins/templates/frontend/views/List.vue.tmpl create mode 100644 shopdb/plugins/templates/manifest.json.tmpl create mode 100644 shopdb/plugins/templates/models/__init__.py.tmpl create mode 100644 shopdb/plugins/templates/models/model.py.tmpl create mode 100644 shopdb/plugins/templates/plugin.py.tmpl create mode 100644 shopdb/plugins/templates/schemas/__init__.py.tmpl create mode 100644 shopdb/plugins/templates/tests/__init__.py.tmpl create mode 100644 shopdb/plugins/templates/tests/test_plugin.py.tmpl create mode 100644 shopdb/static/images/floorplan-placeholder.svg create mode 100644 shopdb/utils/__init__.py create mode 100644 shopdb/utils/apitoken_auth.py create mode 100644 shopdb/utils/authz.py create mode 100644 shopdb/utils/cmmc_usb_db.py create mode 100644 shopdb/utils/employee_db.py create mode 100644 shopdb/utils/import_mode.py create mode 100644 shopdb/utils/mailer.py create mode 100644 shopdb/utils/pagination.py create mode 100644 shopdb/utils/responses.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api_namespace.py create mode 100644 tests/test_core/__init__.py create mode 100644 tests/test_core/test_apitokens.py create mode 100644 tests/test_core/test_auth_ratelimit.py create mode 100644 tests/test_core/test_authz.py create mode 100644 tests/test_core/test_collector_contract.py create mode 100644 tests/test_core/test_collector_printers.py create mode 100644 tests/test_core/test_dashboard_widgets.py create mode 100644 tests/test_core/test_dashboarddefaults.py create mode 100644 tests/test_core/test_email.py create mode 100644 tests/test_core/test_employees.py create mode 100644 tests/test_core/test_identifiers.py create mode 100644 tests/test_core/test_import_mode.py create mode 100644 tests/test_core/test_knowledgebase.py create mode 100644 tests/test_core/test_location_code.py create mode 100644 tests/test_core/test_model_image.py create mode 100644 tests/test_core/test_permissions_catalog.py create mode 100644 tests/test_core/test_plugins_enabled.py create mode 100644 tests/test_core/test_pluginui.py create mode 100644 tests/test_core/test_relationship_directional.py create mode 100644 tests/test_core/test_relationship_propagation.py create mode 100644 tests/test_core/test_reports_hook.py create mode 100644 tests/test_core/test_search_customfields.py create mode 100644 tests/test_core/test_search_disabled.py create mode 100644 tests/test_core/test_search_integrations.py create mode 100644 tests/test_core/test_search_settings.py create mode 100644 tests/test_core/test_setting_model.py create mode 100644 tests/test_core/test_settings_branding.py create mode 100644 tests/test_core/test_slides.py create mode 100644 tests/test_core/test_supportteams.py create mode 100644 tests/test_plugin_contract.py create mode 100644 tests/test_plugin_lifecycle.py create mode 100644 tests/test_plugin_loader.py create mode 100644 tests/test_plugin_migrations.py create mode 100644 tests/test_plugin_scaffold.py create mode 100644 tests/test_plugin_scaffold_frontend.py create mode 100644 tests/test_plugins/__init__.py create mode 100644 tests/test_plugins/test_dualpath_single.py create mode 100644 tests/test_plugins/test_employee_photo.py create mode 100644 tests/test_plugins/test_geenforce_compliance.py create mode 100644 tests/test_plugins/test_geenforce_crud.py create mode 100644 tests/test_plugins/test_geenforce_ddl_parity.py create mode 100644 tests/test_plugins/test_geenforce_export.py create mode 100644 tests/test_plugins/test_geenforce_manifest.py create mode 100644 tests/test_plugins/test_geenforce_payload.py create mode 100644 tests/test_plugins/test_geenforce_reporting.py create mode 100644 tests/test_plugins/test_installed_apps.py create mode 100644 tests/test_plugins/test_measuringtools.py create mode 100644 tests/test_plugins/test_model_image_in_detail.py create mode 100644 tests/test_plugins/test_modelsupplies.py create mode 100644 tests/test_plugins/test_network_ipaddress.py create mode 100644 tests/test_plugins/test_pc_default_printer.py create mode 100644 tests/test_plugins/test_printer_install_list.py create mode 100644 tests/test_plugins/test_shopfloor_feed.py create mode 100644 tests/test_security_config.py create mode 100644 tests/test_smoke.py create mode 100644 wsgi.py diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..5f050a90 --- /dev/null +++ b/.env.example @@ -0,0 +1,88 @@ +# shopdb-flask environment template. +# +# Copy to .env and fill in the values. .env is gitignored. ProductionConfig +# refuses to boot when SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, or +# CORS_ORIGINS are missing or use the dev defaults. +# +# See the DEPLOY wiki page for the full per-site deployment runbook. + +# ---- Flask ---- +FLASK_APP=wsgi.py + +# Set to 'production' for live sites. Other valid values: 'development', +# 'testing'. Production triggers ProductionConfig.validate() at boot. +FLASK_ENV=production + +# ---- Required secrets (production refuses to boot without these) ---- + +# Generate strong random values, e.g.: +# python -c "import secrets; print(secrets.token_urlsafe(64))" +SECRET_KEY=change-this-to-a-secure-random-string +JWT_SECRET_KEY=change-this-to-another-secure-random-string + +# ---- Database (required) ---- + +# Format: mysql+pymysql://:@:/?charset=utf8mb4 +# In docker-compose, host is `db` (the service name). +# The ?charset=utf8mb4 keeps the connection on utf8mb4; create the database as +# utf8mb4 too (see the DEPLOY wiki page). Both must be utf8mb4 to match the schema. +DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@db:3306/shopdb_flask?charset=utf8mb4 + +# ---- CORS (required, no wildcards in production) ---- + +# Comma-separated list of explicit origins permitted to call the API. +# Example for a single-host facility deploy: +# CORS_ORIGINS=https://shopdb.facility-a.example.com +# Wildcard '*' is rejected by ProductionConfig.validate(). +CORS_ORIGINS=http://localhost:5173 + +# ---- JWT lifecycle (optional, defaults shown) ---- +JWT_ACCESS_TOKEN_EXPIRES=3600 +JWT_REFRESH_TOKEN_EXPIRES=2592000 + +# ---- Logging (optional) ---- +LOG_LEVEL=INFO + +# ---- docker-compose only ---- +# These are read by docker-compose.yml; not used by the Flask app directly. +MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD +MYSQL_PASSWORD=CHANGE_ME_APP_PASSWORD +MYSQL_PORT=3306 +API_PORT=5001 + +# ---- Zabbix integration (optional, for printer supply monitoring) ---- +ZABBIX_URL= +ZABBIX_TOKEN= + +# ---- Per-plugin collector API keys (optional) ---- +# Per ADR-006, each plugin can accept external collector input at +# /api/collector/. The framework checks +# COLLECTOR_API_KEY_ first, then COLLECTOR_API_KEY as fallback. +# COLLECTOR_API_KEY= +# COLLECTOR_API_KEY_COMPUTERS= + +# ---- Employee directory database (optional, read-only) ---- +# Separate HR/employee lookup DB consumed by the notifications plugin and the +# public shopfloor kiosks. Leave unset if the feature is not used; there is no +# safe default for the password, so an unset password fails loud. +# EMPLOYEE_DB_HOST= +# EMPLOYEE_DB_USER= +# EMPLOYEE_DB_PASSWORD= +# EMPLOYEE_DB_NAME=wjf_employees + +# ---- cmmc_usb database (USB check-in/out) ---- +# Separate read-write MySQL DB used by the USB plugin to track device +# check-in/out, lockers, and the check-in/out log. Leave unset if the feature +# is not used; there is no safe default for the password, so an unset password +# fails loud. +# CMMC_USB_DB_HOST= +# CMMC_USB_DB_USER= +# CMMC_USB_DB_PASSWORD= +# CMMC_USB_DB_NAME=cmmc_usb + +# ---- Subpath deployment (optional) ---- +# Serve the app under a URL prefix instead of the server root, e.g. as an IIS +# Application at /ops under an existing site. The frontend must be rebuilt with +# the matching base: VITE_BASE_PATH=/ops/ npm run build. Leave unset when the +# app owns its own site/port (the default). See the INSTALL-WINDOWS-IIS wiki page. +# MOUNT_PATH=/ops diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..91bd3db2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.env filter=git-crypt diff=git-crypt diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..9592a83e --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Flask +instance/ +*.db +*.sqlite + +# Testing +.coverage +htmlcov/ +.pytest_cache/ +.tox/ + +# Logs +logs/ +*.log + +# OS +.DS_Store +Thumbs.db + +# Node/Frontend +node_modules/ +frontend/dist/ +*.local + +# Secrets and credentials (defensive) +.env +.env.* +!.env.example +!.env.*.example +*.pem +*.key +id_rsa +id_rsa.* +*.ppk +*.p12 +*.pfx +secrets.json +secrets.yaml +secrets.yml +*_secret +*_secrets +credentials.json +scripts/site_imports/wjf/idmap.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..6997d139 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,476 @@ +# Changelog + +All notable changes to shopdb-flask are recorded here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +The product version (`__version__`) and the plugin-contract version +(`__contract_version__`) are distinct series with independent bump rules; see +ADR-007 and ADR-002. + +## [Unreleased] + +### Changed + +- Asset detail pages (machines, PCs, printers, network devices, measuring + tools) now share one canonical card skeleton: Identity -> type-specific -> + status -> Location & Organization -> domain -> Custom Fields -> Warranty -> + Relationships -> Notes -> audit footer. The location card reads "Location & + Organization" on every page. The network device page was rebuilt into the + family (its "Asset Information" folded into Identity, "Record Info" converted + to the standard audit footer). Printer Notes moved out of mid-page to + second-to-last and the printer gained an audit footer. Template reordering + only; no data or API changes. + +### Fixed + +- List pages keep the current page (and search term) in the URL query, so + paging to page 9, opening an item, and hitting browser Back returns to page 9 + instead of resetting to page 1. Applies to all 18 list views via a shared + useListQuery composable; page 1 with no search stays a bare path. +- PC detail Installed Applications no longer 500s and silently disappears on + real PCs (ComputerInstalledApp had no to_dict); the section renders app + name, version, and description again. +- Employee detail skips its USB panels when the usb plugin is disabled (no + more 404 console noise). +- Shopfloor kiosk header text is readable (light on the dark navy header). + + +### Added + +- Collector-driven PC -> printer relationships. The computers collector schema + gained optional `defaultprinter` (string) and `printers` (array of strings) + fields carrying Win32_Printer identifiers. On ingest each identifier resolves + to a printer asset (by windows name / share / hostname / asset number-name or + a communications IP) and the PC is linked to it: the default via a + `defaultprinter` relationship, the rest via `connectedto`. The links render in + the shared Relationships card on both the PC and printer detail pages. The + sync is idempotent and archives collector-created links to printers no longer + reported (tagged `assetrelationships.label = 'collector:printers'`, so + manually-created links are never touched); unresolved identifiers become + response warnings, never failures. The collector response carries + `printerlinkcount` and a `printerlinks` list. See docs/COLLECTOR-INTEGRATION.md. +- Searchable custom fields. Each custom-field definition gained a `searchable` + flag (Settings > Custom Fields). When on, that field's stored values are + matched by global search and a hit routes to the owning asset's detail page. + The asset's `search__enabled` domain toggle still applies, and matches + dedupe against built-in-field asset hits so an asset appears once. Inactive or + non-searchable fields are never matched. +- Single-label sheet-position printing. The single asset-label page + (`/print/asset-label/:assettype/:id`) gained an Output control that toggles + between the standalone label (unchanged default) and placing that one label at + a chosen cell (1-6, via a 2x3 grid picker) of a ULINE 6-up sheet, leaving the + other five cells blank. This prints a single label onto the correct physical + spot of a partially-used sheet instead of wasting a fresh sheet, mirroring the + legacy shopdb behavior and complementing the batch page's start-cell offset. + The ULINE 6-up cell layout and dimensions are replicated from + `AssetLabelBatch.vue` (left untouched); encode resolution stays shared via + `assetLabel.js`. + +## [0.7.0] - 2026-07-12 + +### Added + +- Email sending. A stdlib-only mail service (`shopdb/utils/mailer.py`; + `smtplib`/`ssl`/`email`) reads the existing `email` SMTP settings + settings-first with an `SMTP_*` env fallback, sends multipart HTML+text, and + is a graceful no-op (logs a warning, returns False) when email is disabled or + the host is unset. The SMTP password is never logged. Three flows use it: + (1) New-user welcome + forced first-login password change. Admin-created users + (POST `/api/users`) are flagged `mustchangepassword` (new `users` column, + migration `7d23_user_mustchangepassword`, default false) and sent a + best-effort welcome email with the facility name, username, temporary + password, and sign-in link; the account is created even if mail fails + (response carries a `warning`). Login returns `mustchangepassword`; the + frontend forces the user through a new `/change-password` view (POST + `/api/auth/change-password`, jwt-guarded) before the app, and changing the + password clears the flag and resets lockout counters. A self-service "Change + password" entry is also available from the user menu. + (2) Test email. POST `/api/settings/test-email` (settings.edit) sends a probe + and surfaces any SMTP error with the password scrubbed; wires up the Email / + SMTP settings page "Send Test Email" button. + (3) On-demand alert/report delivery. POST `/api/reports/email` + (reports.export) mails `{subject, columns, rows}` as an HTML table to a + supplied recipient or the site `alert_recipients`; an "Email report" button on + the Warranty and Toner report pages posts the loaded rows. There is no + scheduler: automation is an external cron hitting the endpoint with a scoped + API token (PAT). Documented in `docs/CONFIG.md`. +- Shared asset label/code generator: a single `/print/asset-label//` + page (public, like the other `/print/*` routes) that any asset detail page + opens via a "Print Label" button (machines, computers, printers, network + devices, measuring tools). A no-print controls panel toggles the layout + (`card` badge vs `plain` code-only), the code type (QR vs CODE128 barcode), + and what the code encodes: the asset page link, asset number, serial number, + a per-type custom target template, or - for measuring tools by default - the + tool's inspection location code so every tool at one operation shares one + code (e.g. `0615`). QR codes reuse the shared logo-overlay renderer. New + `printing` settings seed and surface on the Printing & Labels settings page: + `qr_target_machine`, `qr_target_computer`, `qr_target_network_device`, + `qr_target_measuring_tool`; `label_default_style` (default `card`) and + `label_default_codetype` (default `qr`); and a per-asset-type default for what + the code encodes, `label_default_encodes_` (machines default to their + machine number, measuring tools to their inspection location code, the rest to + a page link), all overridable on the label page itself. When the chosen field + has no value (e.g. serial number on an asset with none), the label states so + instead of rendering an empty code. Asset payloads now carry a derived + `locationcode` (leading token of the resolved own/inherited location name). +- Batch label sheets: a "Print Labels" button on each asset list page opens + `/print/asset-label-batch/`, a multi-select sheet that lays the + chosen assets onto ULINE label pages (6-up 3 in x 3 in, or a dense 72-up + mini-label format), with a start-cell offset to reuse partial sheets. Shares + the same code-type and encode settings/defaults as the single label, so a + batch of measuring tools encodes each tool's inspection location code just + like the single label does. Restores the ULINE batch printing the legacy + shopdb had, generalized across all asset types. +- Support-team contact UX: the settings Support Teams page now manages each + team's contacts in a per-team "Contacts (N)" modal (name, SSO, active, plus + Add/Edit/Delete) instead of an inline row expander, and the application + detail Support card renders Email (`mailto:`) and Microsoft Teams chat + (`teams.microsoft.com/l/chat`) action buttons for every contact that has an + SSO. Both link targets derive as `sso@` from a new `site` setting + `contact_email_domain` (default `geaerospace.com`; blank hides the buttons), + surfaced in Site & Facility settings under Naming & Patterns and read on the + frontend via `getContactEmailDomain()`. +- Plugin `get_permissions` hook (contract 0.10.0) so a plugin declares the RBAC + permissions its own routes enforce, instead of core accumulating every + plugin's permissions in `Permission.PERMISSIONS` (plugin-is-the-product). The + core catalog (`Permission.CORE_PERMISSIONS`) now holds only genuinely core + sets (assets, applications, reports, settings, users, audit, apitokens, + collector); the 36 permissions for machines, computers, printers, network, + knowledgebase, notifications, usb, warranty, and measuringtools moved into + each owning plugin's hook. New core helper `full_permission_catalog()` merges + core plus every ENABLED plugin's permissions and backs all three consumers: + `flask seed permissions`, the role grid (`GET /api/users/permissions`), and + API-token scope validation (`ApiToken.unknown_scope_names`). Plugin install + and enable seed the plugin's own permissions idempotently. A disabled plugin + drops out of the catalog (no new scope grants or role assignments), but its + existing `Permission` rows and role links persist so current roles keep + working. Docs: `docs/PLUGIN-HOOKS.md` new section, `docs/PLUGIN-GUIDE.md` + permissions walkthrough rewritten to the hook, `docs/PLUGIN-QUICKSTART.md` + hooks table row. +- Personal API tokens (PATs) so scripts and integrations authenticate without + the hourly-expiring login JWT (immediate consumer: long legacy-import runs + that die when the JWT expires mid-run). New core `apitokens` table + migration + `7d21_apitokens` (stores only the sha256 hash of each secret; the full secret + `shopdb_pat_<40 hex>` is shown ONCE at creation). New core blueprint + `/api/apitokens` (list own / admin `?all=true`; create; rename or deactivate; + revoke). A `Bearer shopdb_pat_...` header is recognized before any JWT decode + by a before_request shim that mints a request-scoped JWT for the token's + owner, so the entire existing auth+authz stack (jwt_required, + require_permission, require_role, import mode, current_user) authenticates the + PAT as its owner with zero decorator changes; an invalid, revoked, or expired + PAT gets a clean 401. `lastusedat` is stamped on use (throttled to at most one + write per 60s). Any authenticated user manages their own tokens; admins may + list or revoke anyone's. New Settings > API Tokens page (`ApiTokensList.vue`) + with a create modal that reveals the secret once (copy button) and an admin + All Tokens section. Docs: `docs/IMPORT-API.md` and `docs/CONFIG.md` updated to + recommend a PAT for imports. Core feature; no plugin-contract change. +- Optional permission scopes on personal API tokens. A token MAY carry a scopes + list (permission names, migration `7d22_apitokens_scopes` adds the nullable + `apitokens.scopes` JSON column); NULL keeps the original behavior (acts as its + owner). A scoped token grants ONLY the listed permissions, intersected with + what the owner actually holds at use time, and SUSPENDS the admin-role bypass, + so a scoped token minted by an admin is genuinely limited: it is denied on + role-gated (`require_role`) endpoints and gets no import mode. The shim mints + the request JWT with a `patscopes` claim that `require_permission`, + `require_role`, and `import_mode_active` read; normal login JWTs carry no such + claim and are unaffected (zero regression). Scopes are validated at write time + against the token OWNER's permissions (the scope ceiling - a token can never + grant more than its owner holds; when an admin edits another user's token the + ceiling is that owner's permissions), rejecting unknown or unheld names 400. + Minting/managing tokens now requires the new `apitokens.create` permission + (category `apitokens`; admins hold it by default, grantable via the roles UI) + rather than being open to any authenticated user. The Settings > API Tokens + create/edit modals gain a "Restrict permissions" section (a category-grouped + checkbox grid limited to the permissions the creator holds) and the token + lists show a full-access / N-permissions access chip. +- Managed collector service tokens: the collector ingest API + (`/api/collector/` + the legacy `/pc` `/apps` `/heartbeat` `/bulk` + `/status` endpoints) now ALSO accepts a managed API token scoped to the new + `collector.ingest` permission (category `collector`), on top of the existing + `COLLECTOR_API_KEY[_]` env keys (which stay supported as a + bootstrap/legacy fallback - nothing breaks). The token may be presented in + `X-API-Key` (as GE-Enforce sends today) OR as an `Authorization: Bearer` + token; both transports validate the PAT the same way the login shim does + (hash lookup, active, unexpired, active owner) via a shared + `resolve_api_token` helper refactored out of `apitoken_auth.py`, require + `collector.ingest` in the token's scope list AND that the owner holds it, and + stamp `lastusedat` (same 60s throttle). A token scoped to ONLY + `collector.ingest` is a collector service token: it authorizes the collector + API and NOTHING else - the existing scoped-token machinery denies it on every + permission- and role-gated route and on import mode, so a leaked collector + token cannot touch the regular API. Recommended flow (documented): an admin + mints the scoped token (the scope suspends the admin bypass, containing it); + rotate by minting a new one, deploying via `site-config.json`, watching + `lastusedat`, then revoking the old. The Settings > API Tokens create modal + gains a "Collector service token" quick-preset (pre-selects only + `collector.ingest`). Docs: `docs/COLLECTOR-INTEGRATION.md` (new "Managed + collector tokens" section) and `docs/CONFIG.md`. Core feature; no + plugin-contract change. +- Vendor-model photos on asset detail heroes: computers and printers now + surface the linked model's `imageurl` in their extension payloads (the + field machines already exposed), and the machine, PC, printer, network + device, and measuring tool detail pages render the photo in the hero card + when present (hidden cleanly when absent). Network devices and measuring + tools have no model link yet, so their heroes stay photo-less until one + is added. +- Dualpath "single machine" site toggle (`dualpath_single_machine`, default + on). A Dualpath relationship pair is one physical dual-bay machine (single + controller, bay-selector switch); when on, the machines list, dashboard and + machines-by-type counts, and the floor map collapse each pair to one entry + (the lower natural-sort assetnumber is PRIMARY; the SECONDARY bay is hidden) + and show a combined `2007 / 2008` label. The data model is unchanged (both + bay records always exist); detail pages stay per-bay and always show a + sibling-bay banner regardless of the toggle. Contract surface (plugin + contract bumped 0.8.0 -> 0.9.0, additive): new `shopdb.api` helpers + `resolve_dualpath_pairs` and `dualpath_single_machine_enabled`, consumed by + the machines plugin to collapse pairs contract-purely. +- Relationship propagation, wired and data-driven: relationship types + declare propagation-through pairs (relationshiptypepropagations M:N, + replacing the never-consumed single column); creating a controls link on + one Dualpath bay auto-creates it on the partner bay, and + `flask relationships propagate` backfills existing data. +- Employee photos, mode-aware: self-hosted directory employees support + upload/replace/delete (admin), served publicly for kiosk cards; external + directory mode passes the HR-supplied picture URL through read-only. One + resolver feeds the shopfloor recognition/recert cards and the employee + detail hero in either mode. +- Vendor-model photo management. New admin-gated core endpoints + `POST /api/models//image` (multipart `file`, png/jpg/jpeg/gif/webp/svg, + one image per model, replace semantics) and + `DELETE /api/models//image`, plus the public + `GET /api/models/image/` serve route. Uploads land in + `instance/modelimages/` (survives upgrades, backed up with the rest of + `instance/`) and set `models.imageurl` to the served URL; the manual Image URL + field still accepts external URLs and the shipped `/images/models/*` assets + (upload is additive). Delete only removes files we own under the instance dir. + The Models settings page grows a thumbnail, Upload/Replace, and Remove + controls in the edit modal. Asset hero images (e.g. the machine badge) read + `imageurl` unchanged, so uploaded photos render with no consumer changes. + +- Application support teams with contacts, replacing the legacy + supportteams/appowners pair. New core `supportteamcontacts` table (multiple + named contacts per team, ordered by `sortorder`); `supportteams` keeps + `teamname` (now unique) and `teamurl` (a ServiceNow group deep link) and + sheds the single-owner `appownerid` FK. New core blueprint at + `/api/supportteams` (team + nested contact CRUD, admin-gated; `?teamname` + exact-match lookup for import; delete a team 409s while any application still + references it). Migration `7d18_supportteamcontacts` migrates each legacy + team's app owner into one contact. Application payloads now flatten + `supportteamname`, `teamurl`, and the team's active `contacts`; a Support + card on the application detail page and a new `settings/supportteams` + management page render them. + +- Import mode: a complete, idempotent HTTP migration surface so a migration script + can import the classic ASP shopdb through the API alone (no direct DB writes). + - Contract surface (plugin contract bumped 0.7.0 -> 0.8.0, additive): new + `shopdb.api` helpers `apply_import_timestamps`, `import_mode_active`, + `parse_import_datetime` in `shopdb/utils/import_mode.py`. When the caller is + an admin AND sends header `X-Import-Mode: true`, create/update endpoints + accept optional `createddate` / `modifieddate` (ISO or legacy + `YYYY-MM-DD HH:MM:SS`, naive-UTC) and preserve them instead of stamping now. + Non-admin or missing header: the fields are ignored exactly as before. + Wired into every timestamped import target: assets (all five type plugins), + vendors, models, modeltypes, businessunits, locations, operating systems, + applications, knowledge base, USB devices, and asset relationships. + - Natural-key exact-match lookup filters for the documented + lookup-then-upsert idempotency recipe: `assetnumber` on all five asset + plugin list endpoints; `vendor`, `modelnumber`, `modeltype`, + `businessunit`, `locationname`, `osname`/`osversion`, `appname`, + knowledge base `linkurl`/`shortdescription`, warranty `servicetag`/`vendor`, + and notification `ticketnumber`. + - Backdated event history: in import mode the selfhosted USB checkout/checkin + endpoints accept optional `checkouttime` / `checkintime` overrides so + migrated `usbcheckouts` rows keep their real event times. + - New operator manual `docs/IMPORT-API.md` grounded in the real `prodscratch` + legacy schema: order of operations, a full table-by-table mapping, honest + no-target list with dispositions, a worked idempotent Python importer, and + row-count parity checks. + +### Changed + +- System Settings is no longer one tabbed page. The inner tab bar is gone and + each section is its own routed settings page reached through the settings + rail: ServiceNow, Zabbix Supplies, Dell Warranty, and Collector PC Types + (the former Integrations dumping ground, now split three-plus ways), plus + Branding, Floor Map, Printing & Labels, Email / SMTP, Authentication, + Audit & Logging, Asset Identifiers, and Global Search. The rail regroups + these under Site & Facility, Integrations, Communication, Search & Identity, + and Access & Security. Shared load/save/upload plumbing moved into a + `useSystemSettings` composable so the pages stay thin. Old bookmarks keep + working: `/settings/system` and every `/settings/system?tab=` redirect + to the matching new page. + +### Fixed + +- Audit log: hovering a user's SSO now shows their full name (best-effort, + resolved from the employee directory in either mode). +- Refreshed the internal status docs to match the code (project active + state, CONTRACT-STABILITY.md and README plugin list at contract 0.10.0), + corrected the get_asset_panels endpoint path in the hook docstring, and + removed leftover debug console.log lines. +- Measuring tools wired into the remaining cross-cutting surfaces an audit + found them missing from: generic asset serialization (typedata + pluginid, + which also fixes relationship-card links to tools), map subtype + filtering/coloring and the MapEditor filter, dashboard totals, warranty + asset links (via a new by-asset detail route), and the two ADR-010 hook + declarations (presentation route token corrected; the calibration + map-overlay endpoint now actually exists). The login avatar also resolves + through the employee-photo helper, so self-hosted uploads show. +- Measuring tools are now wired into the asset-identifier matrix and global + search. The Settings identifier matrix gains a Measuring Tool column and the + gauge-lab and maintenance reference inputs/rows on the measuring-tool form + and detail pages honor those per-type toggles (a maintenance-reference field + was added, matching the other asset types). Global search gains a Measuring + Tools domain toggle and filter chip, routes measuring-tool hits to + `/measuringtools/` (previously the generic `/assets/` fallback), and + matches on `gaugelabreference` so a gage-tag lookup resolves the tool. +- Site & Facility settings page renders booleans as toggles and the + directory-mode settings as dropdowns, with labels and help text for every + field (no more raw keys or type-true/false boxes). +- System Settings tabs follow the URL: clicking a settings-rail link that + only changes the ?tab= query (Branding, Floor Map) now switches the right + panel, tab clicks update the URL, and browser back/forward restore tabs. +- Following a relationship link between two assets of the same type now loads + the destination page instead of stale content (router-view keyed on path; + query-only URL changes still avoid a remount). +- Asset relationships card no longer lists a symmetric peer twice. Relationship + types gain `relationshiptypes.isdirectional` (migration + `7d19_relationshiptype_directional`; seeded false for the connection-like + types Dualpath, connectedto, Cluster Member, Serial Cable, Direct Ethernet, + USB, WiFi, true for controls/Controlled By/Backup For/Master-Slave/partof/ + defaultprinter). The card now collapses every stored direction row of a + symmetric type into one direction-blind "Connected" entry per peer (deleting + it removes all collapsed rows), while directional types drop the + Outgoing/Incoming headers for inline `Type -> peer` / `<- Type from peer` + phrasing. The type CRUD and the per-asset relationships endpoint carry + `isdirectional`; the Relationship Types settings page gains a Directional + toggle. + +## [0.6.0] - 2026-07-11 + +### Added + +- measuringtools plugin (ADR-005): gage-lab instruments as Asset extensions + with type lookup (color-coded), calibration tracking (derived + overdue/due-soon/current status), calibration report, and full frontend. + Built as the framework exemplar; docs/PLUGIN-GUIDE.md walks through its + construction step by step as the plugin-system tutorial. +- CSV export on the Warranty and Toner report pages; per-report filter + controls (business unit, asset type, location, application, limit) on the + inline core reports; report open-state is URL-backed and deep-linkable. +- Per-plugin Alembic migration chains (ADR-008): every bundled plugin now + carries its own chain with a stamp-only anchor; new plugin schema changes + land in `plugins//migrations/`, never the core chain. Deploys run + `flask plugin upgrade-all` after `flask db upgrade`. +- Frontend plugin route gating (ADR-009): a disabled backend plugin's pages + redirect to the dashboard; new public `GET /api/plugins/enabled`. +- `get_reports()` plugin hook (plugin contract 0.5.0 -> 0.6.0): plugins + contribute their own report cards; warranty and toner cards moved off the + hardcoded frontend list. +- Reports hub grouped by category with a search filter. +- Configurable QR label targets: `qr_target_printer` / `qr_target_usb` + settings (blank = the asset's own page, else a URL template with + placeholders) and a `usb_label_style` barcode/QR toggle for USB + mini-labels. New Settings > Printing & Labels section. +- Site palette theming: optional `brand_primary_dark_color`, + `brand_accent_color`, `brand_sidebar_color` settings applied at bootstrap. +- Collector integration guide rewrite: header-only auth reference and a + paste-ready GE-Enforce PowerShell reporting function. + +### Changed + +- Equipment -> machines rename (backend). The equipment plugin is now the + machines plugin: `/api/equipment` -> `/api/machines`, tables + `equipment`/`equipmenttypes` -> `machines`/`machinetypes` (columns + `equipmentid` -> `machineid`, `equipmenttypeid` -> `machinetypeid`, + `equipmenttype` -> `machinetype`), permissions `equipment.*` -> + `machines.*`, assettype value `equipment` -> `machine`. The legacy core + `machinetypes` lookup (it types the vendor MODELS catalog, not machine + instances) is renamed to `modeltypes` (`/api/machinetypes` -> + `/api/modeltypes`, `models.machinetypeid` -> `models.modeltypeid`) to + free the name. Data flips migrate assettypes, auditlog entitytype, + settings keys (`identifier_*_equipment_enabled` -> + `identifier_*_machine_enabled`, `search_equipment_enabled` -> + `search_machine_enabled`), and permission rows in place; plugins.json + registry entries carry over automatically. Upgrade: run + `flask db upgrade` then `flask plugin upgrade-all`. + +- Inter (variable) replaces Roboto, bundled locally - no Google Fonts + fetch, so air-gapped installs render correctly. Tables use tabular + numerals. +- ServiceNow defaults point at the current geaerospaceqa.service-now.com + global search (search, incident, and change links). + +### Fixed + +- USB frontend remapped to the actual API shape (`device_id` / + `device_desc`): device list, detail, form, label batch, and the employee + profile's checked-out/history panels were all reading dead legacy fields. +- External-mode `GET /api/usb/checkouts/active` now honors the `badge` + filter. +- Warranties list page no longer demands login (matches every other list + page; reads were already public). +- Removed the dead legacy Warranty Status report (always-zero buckets from + a retired column); the warranty plugin's report is the real one. +- Pruned dead usbApi client methods that had no backend routes. + +## [0.5.0] - 2026-07-10 + +First release cut with a version, tag, changelog, and CI. Focused on +letting other GE Aerospace sites stand up their own self-hosted instance +(single-tenant per ADR-004). + +### Added + +- First-run setup wizard (`/setup`): creates the initial superadmin + in-app, configures each plugin (create tables here vs connect your own + database), uploads light/dark floor-map blueprints, and seeds starter + reference data. +- Self-hosted employee directory and USB plugins: in-app management plus + CSV import, no external database required. Both ship default-disabled + with an enable-time provisioning note. +- Dell warranty plugin: real Dell provider, bulk warranty sync, + add-warranty from asset pages, PC hero warranty badge, disk-cached Dell + API token. +- Custom fields, and a two-pane settings shell with tabbed, searchable + System Settings and Settings index pages. +- Dashboard defaults (visitor-IP to business-unit mapping) for kiosk + displays; printer installer endpoint (data plus floor-map positions). +- Global toast notifications replacing `alert()` calls. +- Multi-stage Docker build that compiles the Vue frontend and ships + `frontend/dist`, which Flask serves. +- Documentation overhaul: new CONFIG, UPGRADE, and BACKUP-RESTORE guides; + reconciled README, DEPLOY, status docs, and ROADMAP. +- ADR-007 (product versioning and releases), CHANGELOG, and best-effort + CI (backend tests, naming/style gate, frontend build). + +### Changed + +- Plugin contract (`__contract_version__`) settled at 0.5.0: full plugin + import surface exposed via `shopdb.api`, dead search hook removed, and + the dashboard-widgets hook wired to a real consumer. +- Role-based access control now enforced on write routes, including + admin-only guards on dashboard-defaults writes. +- Branding, ServiceNow integration, employee-ID pattern, printer + hostname template, and floor-plan blueprints are settings-driven and + per-site configurable, with GE defaults preserved as shipped fallbacks + (branding and floor-plan configurability landed in this release; some + consumer wiring continues under Unreleased). + +### Security + +- Dashboard-defaults writes now require admin authorization instead of + any authenticated user. +- Collector error responses no longer leak exception detail; failures are + logged server-side with generic client-facing messages. +- Login rate limiting added (IP-based fixed window) on top of the + existing account lockout. + +### BREAKING + +- Collector API key must now be sent in the `X-API-Key` header. The + api-key-in-querystring fallback has been removed. Update any collector + integration that passed the key as a query parameter. See + `docs/COLLECTOR-INTEGRATION.md`. + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..db0f47aa --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,162 @@ +# Contributing to ShopDB Flask + +## Coding Standards + +### Naming principles + +1. Spell it out. If a non-technical user looking at a report would not understand a column at a glance, rename it. +2. No project-specific shorthand. A name either uses a word in full, or uses an acronym from the allowed list below. +3. Consistency beats cleverness. Once a name is committed and used in code, do not rename it for marginal improvement. +4. When unsure, ask: "would a new shop-floor IT person understand this?" + +### Database + +- **Table names**: lowercase, concatenated, plural. No dashes, no underscores. + - Good: `machines`, `pctypes`, `networkdevices`, `businessunits`, `auditlogs` + - Bad: `machine_types`, `network-devices`, `BusinessUnits` +- **Column names**: lowercase, concatenated, singular. No dashes, no underscores. + - Good: `machineid`, `machinenumber`, `lastzabbixsync`, `isactive` + - Bad: `machine_id`, `last_zabbix_sync`, `IsActive` +- **Foreign keys**: referenced table name (singular) + `id`. + - Good: `locationid` references `locations`, `vendorid` references `vendors` +- **Booleans**: prefix with `is` or `has`. + - Good: `isactive`, `isshopfloor`, `hasprinter` +- **Index names**: `idx__`. Underscores allowed here only because indexes are infrastructure, not data. + - Good: `idx_machines_locationid` + +### Allowed acronyms (closed list, expand by PR) + +- **Universal**: `id`, `url`, `api`, `http`, `https`, `json`, `jwt`, `sql`, `os`, `ip`, `dns`, `csv`, `pdf`, `cors`, `ttl`, `uuid`, `html`, `css`, `orm` +- **Domain (shop floor / IT infra)**: `cmm`, `cnc`, `pc`, `usb`, `vnc`, `winrm`, `ssh`, `ssl`, `tls`, `tcp`, `udp`, `smtp`, `ldap`, `vlan`, `sso`, `dnc`, `focas`, `clm`, `mtconnect` + +Any acronym not on these lists must be spelled out. Proposing a new acronym is a separate PR that updates this list. + +**Third-party imports are exempt.** Library and stdlib module paths cannot be renamed. `from sqlalchemy.orm import ...`, `import importlib.util`, `from werkzeug.utils import ...` are all fine even if they contain words that would otherwise be banned. + +### Banned shorthand + +`cfg`, `ctx`, `mgr`, `req`, `res`, `env`, `util`, `helper`, and any other domain-specific shortening. Spell it out: `config`, `context`, `manager`, `request`, `response`, `environment`, `utilities`. Note: `db` as a standalone variable name is banned, but `db` as a prefix in `database` or as part of project name `shopdb` is fine. + +**Suffix exception.** Banned shorthand applies to standalone identifiers. As a suffix where the prefix establishes meaning, it is acceptable: + +- `printers_bp`, `auth_bp`, `network_bp` are fine. The prefix names what blueprint, the `_bp` suffix is conventional for Flask blueprints. +- `request_obj` is fine when disambiguating from imported `request`. +- `bp` alone as a variable name is not fine. Always pair with a meaningful prefix. + +### Code + +#### Python + +- **Variables and functions holding a database value**: match the column name exactly. No conversion to snake_case. + - DB column `machineid` -> Python variable `machineid`, attribute `Machine.machineid`, dict key `{"machineid": 1}` + - DB column `lastzabbixsync` -> Python variable `lastzabbixsync` +- **Pure code variables and functions** (loop counters, helpers, anything that does not mirror a DB field): snake_case per PEP 8. + - Good: `loop_count`, `current_user`, `validate_input()` +- **Classes**: PascalCase. Spell out fully. + - Good: `PrinterData`, `AssetType`, `NetworkDevice` +- **Modules / file names**: lowercase. Underscores allowed where it improves readability (PEP 8 permits this for modules). + - Good: `employee_service.py`, `asset_routes.py` + +#### JavaScript / Vue + +- **Variables**: camelCase per JS convention. + - Good: `currentUser`, `isLoading` +- **Variables holding API field values**: match the API field name exactly. Do NOT convert to camelCase. + - API returns `{"machineid": 1}` -> JS `response.machineid`, NOT `response.machineId` + - API returns `{"lastzabbixsync": "..."}` -> JS `device.lastzabbixsync` +- **Components**: PascalCase. Spell out fully. + - Good: `AssetDetail.vue`, `MachineForm.vue`, `PrinterList.vue` +- **CSS classes**: lowercase with dashes. + - Good: `asset-detail`, `machine-form` + +#### API + +- **Endpoints**: lowercase, plural nouns. No underscores or dashes. + - Good: `/api/machines`, `/api/networkdevices`, `/api/businessunits` +- **Query parameters**: lowercase, concatenated. Match column names where applicable. + - Good: `?locationid=5`, `?isactive=true` +- **Response keys**: match database column names exactly. + - Good: `{"machineid": 1, "lastzabbixsync": "2026-01-12T10:00:00Z"}` + +### Examples + +```python +# Good - DB-mirrored fields keep column names; pure code uses snake_case +class PrinterData(db.Model): + __tablename__ = 'printerdata' + + machineid = db.Column(db.Integer, db.ForeignKey('machines.machineid')) + lastzabbixsync = db.Column(db.DateTime) + isnetworkprinter = db.Column(db.Boolean) + + +def list_printers_by_location(locationid: int) -> list[dict]: + printers = PrinterData.query.filter_by(locationid=locationid).all() + result_count = len(printers) + return [{"machineid": p.machineid, "isnetworkprinter": p.isnetworkprinter} for p in printers] +``` + +```python +# Bad - PEP 8-style underscores on DB fields, banned shorthand, project acronym not on allowed list +class PrinterData(db.Model): + __tablename__ = 'printer_data' + + machine_id = db.Column(db.Integer) + last_zabbix_sync = db.Column(db.DateTime) + is_net_prn = db.Column(db.Boolean) + + +def list_prns_by_loc(loc_id): + bp_ctx = get_context() + cfg = load_cfg() + return PrinterData.query.filter_by(loc_id=loc_id).all() +``` + +### Style policy (non-naming) + +- No emojis in code, comments, documentation, string literals, or UI. +- No em-dashes (U+2014), en-dashes (U+2013), Unicode arrows, or smart quotes anywhere. Plain ASCII only. +- Comments default to none. Add one only when the WHY is non-obvious. Keep comments terse (lite caveman style is fine for inline `#` and `//` comments). Docstrings stay normal English. +- Dark theme is default. Keep UI functional and professional. + +### File structure + +- Core models: `shopdb/core/models/` +- Core API routes: `shopdb/core/api/` +- Core services: `shopdb/core/services/` +- Plugin code: `plugins//{models,api,services,schemas}/` + +### Plugin development + +When creating a new plugin: + +1. Create directory `plugins//`. Use the scaffold (`flask plugin new `) when available. +2. Include `manifest.json` with metadata. This is the single source of truth for plugin name, version, dependencies, api_prefix. +3. Extend `BasePlugin` class. +4. Follow naming conventions above. +5. Run `flask plugin install ` to install. + +See the PLUGIN-QUICKSTART wiki page (when published) for the 30-minute walkthrough. + +## Testing + +Run tests with: + +```bash +pytest tests/ +``` + +Tests are required for: +- New routes (smoke + happy path + 1-2 edge cases) +- New plugin hooks (contract test) +- Schema migrations (before/after data integrity) + +## Code review checklist + +- [ ] No underscores in table or column names +- [ ] No banned shorthand or unapproved acronyms in any new identifier +- [ ] DB-mirrored Python and JS variables match column names exactly +- [ ] API response keys match column names exactly +- [ ] No em-dashes, en-dashes, smart quotes, Unicode arrows, or emojis (run `grep -rP '[^\x00-\x7F]' --include='*.py' --include='*.vue' --include='*.js' .`) +- [ ] Plugin follows `BasePlugin` interface and contract version +- [ ] Tests included for new functionality diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..9450c595 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,80 @@ +# shopdb-flask single-site container. +# +# One image, one site. Per ADR-004, each adopting facility runs its own +# stack with its own DB, secrets, and enabled-plugin list. This image +# bundles all eleven core plugins (computers, employees, knowledgebase, +# machines, measuringtools, network, notifications, printers, slides, usb, +# warranty); +# install them at runtime with `flask plugin install `. +# +# The frontend is built in a first stage and its dist output is copied into +# the final image so Flask can serve the SPA (register_frontend_routes in +# shopdb/__init__.py resolves /frontend/dist). +# +# Build: +# docker build -t shopdb-flask . +# Run (with .env): +# docker run --env-file .env -p 5001:5001 shopdb-flask + +# ---- Stage 1: build the Vue frontend ---- +FROM node:20-slim AS frontendbuild + +WORKDIR /build + +# Copy only the manifests first so `npm ci` caches on dependency changes. +COPY frontend/package*.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build +# Output lands in /build/dist (Vite default), copied into the final stage below. + +# ---- Stage 2: Python application image ---- +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + default-libmysqlclient-dev \ + pkg-config \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt \ + && pip install --no-cache-dir gunicorn + +COPY shopdb/ ./shopdb/ +COPY plugins/ ./plugins/ +COPY migrations/ ./migrations/ +COPY scripts/ ./scripts/ +COPY wsgi.py ./ + +# Built SPA from stage 1. Flask serves it via register_frontend_routes. +COPY --from=frontendbuild /build/dist ./frontend/dist + +RUN useradd --create-home --shell /bin/bash shopdb \ + && chown -R shopdb:shopdb /app +USER shopdb + +EXPOSE 5001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl --fail --silent http://localhost:5001/api/auth/login -X POST \ + -H "Content-Type: application/json" -d '{}' \ + | grep -q "VALIDATION_ERROR" || exit 1 + +CMD ["gunicorn", \ + "--bind", "0.0.0.0:5001", \ + "--workers", "4", \ + "--timeout", "60", \ + "--access-logfile", "-", \ + "--error-logfile", "-", \ + "wsgi:app"] diff --git a/README.md b/README.md new file mode 100644 index 00000000..f56f740f --- /dev/null +++ b/README.md @@ -0,0 +1,243 @@ +# ShopDB Flask + +A modern rewrite of the classic ASP/VBScript ShopDB application using Flask (Python) and Vue 3. This application manages shop floor machines, PCs, printers, applications, and related infrastructure for manufacturing environments. + +## Overview + +ShopDB tracks and manages: +- **Machines** - CNC equipment, CMMs, inspection systems, etc. +- **PCs** - Shopfloor computers, engineering workstations +- **Printers** - Network printers with Zabbix supply integration +- **Network devices** - Switches, routers, and the subnet browser +- **Measuring tools** - Gage-lab instruments with calibration tracking +- **Applications** - Software deployed across the shop floor, with per-PC install tracking +- **Employees** - Directory, recognition and training notifications +- **Warranties** - Coverage records with Dell warranty lookups +- **USB devices** - CMMC check-in/out tracking +- **Knowledge Base** - Documentation and troubleshooting guides +- **GE-Enforce manifests** - Imaging/software manifest editing and fleet compliance + +## Tech Stack + +**Backend:** +- Python 3.12 with Flask +- SQLAlchemy ORM +- MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see the DEPLOY wiki page) +- JWT authentication +- Plugin architecture for extensibility + +**Frontend:** +- Vue 3 with Composition API +- Vue Router for navigation +- Pinia for state management +- Vite build system + +## Project Structure + +``` +shopdb-flask/ +├── shopdb/ # Flask application +│ ├── core/ +│ │ ├── api/ # REST API endpoints +│ │ ├── models/ # SQLAlchemy models +│ │ ├── schemas/ # Validation schemas +│ │ └── services/ # Business logic +│ ├── plugins/ # Plugin system +│ └── utils/ # Shared utilities +├── frontend/ # Vue 3 application +│ ├── src/ +│ │ ├── api/ # API client +│ │ ├── components/ # Reusable components +│ │ ├── views/ # Page components +│ │ ├── router/ # Route definitions +│ │ └── stores/ # Pinia stores +│ └── public/ # Static assets +├── plugins/ # Bundled and external plugins +├── migrations/ # Alembic migration chain (flask db upgrade) +├── scripts/ # Import and utility scripts +└── tests/ # Test suite +``` + +## Naming Conventions + +To maintain consistency with the legacy ShopDB database and codebase, the following naming standards apply: + +### Database + +- **Table names:** Lowercase, single word, no underscores or dashes + - Examples: `assets`, `computers`, `printers`, `businessunits` +- **Column names:** Lowercase, single word, no underscores or dashes + - Examples: `assetid`, `assetnumber`, `hostname`, `isactive`, `createddate` +- **Foreign keys:** Referenced table name + `id` + - Examples: `locationid`, `vendorid`, `modelnumberid`, `computertypeid` +- **Boolean columns:** Prefixed with `is` or `has` + - Examples: `isactive`, `isshopfloor`, `iscolor`, `isdhcp`, `islicenced` + +### Code + +- **Python variables:** Follow database naming where applicable (lowercase, no underscores for model fields) +- **JavaScript variables:** camelCase for local variables, but match API field names from backend +- **Vue components:** PascalCase for component names +- **CSS classes:** Lowercase with dashes for multi-word classes + +### API + +- **Endpoints:** Lowercase, plural nouns + - Examples: `/api/machines`, `/api/computers`, `/api/locations` +- **Query parameters:** Lowercase, single word + - Examples: `?locationid=5`, `?isactive=true`, `?assettype=computer` + +## Style Guidelines + +- No emojis in code, comments, documentation, or UI +- Keep UI functional and professional +- Dark theme is the default +- Consistent table layouts across all list views + +## Setup + +### Prerequisites + +- Python 3.12 +- Node.js 18+ +- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see the DEPLOY wiki page) + +ShopDB Flask uses MySQL as the canonical database. SQLite is used only for the +test suite (`TestingConfig` in `shopdb/config.py` points at an in-memory +SQLite). Do not run dev or production against SQLite. + +### Distribution + +The application is distributed through the internal GE Aerospace git server. Clone +it from there; there is no public package or image registry. + +### Fast path (Docker) + +The Docker image builds the Vue frontend and serves it from the API container, +so a container deploy needs no separate Node build step. + +```bash +cp .env.example .env +# Edit .env: set SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, CORS_ORIGINS, +# and the MYSQL_* passwords (see the CONFIG wiki page for every variable). + +docker compose up -d --build + +# Create the schema and seed the platform data (idempotent, safe to re-run): +docker compose exec api flask db upgrade +docker compose exec api flask seed permissions +docker compose exec api flask seed settings +docker compose exec api flask seed reference-data +``` + +Then browse to the site and complete the first-run setup wizard at `/setup` +(it creates the first admin account and captures site identity). To create the +admin headlessly instead of using the wizard: + +```bash +docker compose exec api flask seed admin --username admin --email admin@facility.example.com +``` + +### Manual path (venv + Node) + +```bash +# Backend +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +# Edit .env with your database credentials and secrets. + +flask db upgrade +flask seed permissions +flask seed settings +flask seed reference-data +flask run + +# Frontend (separate terminal) +cd frontend +npm install +npm run dev # dev server on :5173 +npm run build # production build into frontend/dist (served by Flask) +``` + +Complete first-run setup at `/setup`, or run `flask seed admin` for a headless +admin account. + +To import a site's legacy data, use the HTTP import surface: an admin API +token plus the IMPORT-API page in the project wiki drive the whole migration +through documented endpoints (`X-Import-Mode` preserves original timestamps). +`scripts/site_imports/wjf/` is the West Jefferson reference loader. + +For the full per-site deployment runbook see the DEPLOY page in the project wiki; +for every environment variable and Setting key see the CONFIG page in the project wiki. + +## Configuration + +Environment variables (`.env`): + +| Variable | Description | +|----------|-------------| +| `DATABASE_URL` | MySQL connection string | +| `SECRET_KEY` | Flask secret key | +| `JWT_SECRET_KEY` | JWT signing key | +| `JWT_ACCESS_TOKEN_EXPIRES` | Access token TTL (seconds) | +| `LOG_LEVEL` | Logging verbosity | + +## API Documentation + +The REST API follows standard conventions: + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/machines` | List machines (filterable by type) | +| GET | `/api/machines/:id` | Get machine details | +| POST | `/api/machines` | Create machine | +| PUT | `/api/machines/:id` | Update machine | +| DELETE | `/api/machines/:id` | Soft delete machine | + +Each asset plugin exposes the same CRUD pattern on its own prefix +(`/api/computers`, `/api/printers`, `/api/network`, `/api/measuringtools`), +and cross-cutting asset endpoints live under `/api/assets`. + +Query parameters for list endpoints: +- `page` - Page number (default: 1) +- `perpage` - Items per page +- `sort` - Sort field +- `dir` - Sort direction (asc/desc) +- `search` - Search term +- `assettype` - Filter by asset type (computer, printer, machine, networkdevice, measuringtool) + +## Plugin System + +ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for plugin development guidelines. + +The image bundles twelve plugins; only the ones a site installs are loaded: + +- **computers** - Shopfloor PCs and workstations, collector fleet ingest +- **employees** - Employee directory +- **geenforce** - GE-Enforce imaging/software manifests and fleet compliance +- **machines** - CNC, CMM, and other shop-floor machines +- **measuringtools** - Gage-lab instruments with calibration tracking +- **knowledgebase** - Documentation and troubleshooting guides +- **network** - Network devices and subnets +- **notifications** - Shopfloor notifications and recognition feed +- **printers** - Extended printer management with Zabbix integration +- **slides** - TV/kiosk slideshows +- **usb** - CMMC USB check-in/out tracking +- **warranty** - Warranty records with Dell lookups + +## Legacy Migration + +This project replicates functionality from the classic ASP/VBScript ShopDB site. Key mappings: + +| Legacy | Modern | +|--------|--------| +| ASP/VBScript | Flask/Python | +| Classic ADO | SQLAlchemy | +| Server-side HTML | Vue 3 SPA | +| Session auth | JWT tokens | + +## License + +Internal use only. diff --git a/deploy/windows/web.config b/deploy/windows/web.config new file mode 100644 index 00000000..fe712b0d --- /dev/null +++ b/deploy/windows/web.config @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..8a7d62d3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,65 @@ +# shopdb-flask single-site docker-compose template. +# +# Per ADR-004, each adopting facility runs its own stack. This template +# brings up MySQL + the API container and exposes the API on port 5001. +# The Vue frontend is served separately by the API in production builds +# (see register_frontend_routes in shopdb/__init__.py); for dev, run +# `npm run dev` in frontend/ on a separate port. +# +# Usage: +# cp .env.example .env +# # edit .env with site-specific secrets and origins +# docker compose up -d +# +# Refresh after pulling new code: +# docker compose build api +# docker compose up -d api + +services: + db: + image: mysql:8.0 + # utf8mb4 server-wide so the auto-created MYSQL_DATABASE is utf8mb4, not the + # image default. Keeps every site's schema on the same charset/collation. + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} + MYSQL_DATABASE: shopdb_flask + MYSQL_USER: shopdb + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set} + volumes: + - db_data:/var/lib/mysql + # Bind MySQL to loopback only. The api container reaches db over the + # compose network regardless of this mapping; the published port is just + # for local admin tools (mysqldump, a client on the host). Exposing 3306 + # on all interfaces would put the database on the facility network. + ports: + - "127.0.0.1:${MYSQL_PORT:-3306}:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + + api: + build: . + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + FLASK_ENV: production + DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4 + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set} + CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + ZABBIX_URL: ${ZABBIX_URL:-} + ZABBIX_TOKEN: ${ZABBIX_TOKEN:-} + ports: + - "${API_PORT:-5001}:5001" + volumes: + - ./plugins:/app/plugins:ro + +volumes: + db_data: diff --git a/frontend/DEVELOPMENT-STANDARDS.md b/frontend/DEVELOPMENT-STANDARDS.md new file mode 100644 index 00000000..700750f8 --- /dev/null +++ b/frontend/DEVELOPMENT-STANDARDS.md @@ -0,0 +1,120 @@ +# Frontend Development Standards + +## Naming Convention (LOCKED) + +The shopdb-flask naming convention lives in the root `CONTRIBUTING.md`. Read it before naming any variable, component, API param, or CSS class. + +Frontend-specific reminders pulled from the convention: + +- Variables holding API field values: match the API field name exactly. Do NOT convert to camelCase. (`response.machineid`, NOT `response.machineId`) +- Pure JS variables: camelCase (`currentUser`, `isLoading`) +- Vue components: PascalCase, spelled out (`AssetDetail.vue`, `MachineForm.vue`) +- CSS classes: lowercase with dashes (`asset-detail`, `machine-form`) +- API params sent to backend: match DB column names without underscores (`params.locationid = 5`, NOT `params.location_id`) +- No emojis, em-dashes, smart quotes, or Unicode arrows anywhere. Plain ASCII only. +- Banned shorthand as standalone variables: `cfg`, `ctx`, `mgr`, `req`, `res`, `env`, `util`, `helper`. Spell them out (`canvasContext`, `response`, `manager`, etc.). Suffix usage like `printers_bp` is allowed. + +Pre-commit hook at `scripts/check-naming-and-style.sh` enforces these rules. + +## CSS Styling Standards + +### Use CSS Variables for ALL Colors + +**NEVER hardcode colors in component styles.** Always use CSS variables defined in `src/assets/style.css`. + +Available variables: +```css +--primary /* Primary brand color */ +--primary-dark /* Darker primary for hover states */ +--secondary /* Secondary/muted color */ +--success /* Success states (green) */ +--warning /* Warning states (orange) */ +--danger /* Error/danger states (red) */ +--bg /* Page background */ +--bg-card /* Card/panel background */ +--text /* Primary text color */ +--text-light /* Secondary/muted text */ +--border /* Border color */ +--link /* Link color (bright blue in dark mode) */ +``` + +**Bad:** +```css +.my-card { + background: white; + color: #1a1a1a; +} +``` + +**Good:** +```css +.my-card { + background: var(--bg-card); + color: var(--text); +} +``` + +### Detail Pages - Use Global Styles + +All detail pages (MachineDetail, PCDetail, PrinterDetail, ApplicationDetail) should use the **unified global styles** from `style.css`: + +- `.detail-page` - Container wrapper +- `.hero-card` - Main hero section with image and info +- `.hero-image`, `.hero-content`, `.hero-title`, `.hero-meta`, `.hero-details` +- `.section-card` - Info sections +- `.section-title` - Section headers +- `.info-list`, `.info-row`, `.info-label`, `.info-value` +- `.content-grid`, `.content-column` - Two-column layout +- `.audit-footer` - Created/modified timestamps + +**Only add scoped styles for page-specific elements** (e.g., supplies grid for printers, version list for applications). + +### PrinterDetail.vue is the Master Template for Detail Pages + +Use `PrinterDetail.vue` as the reference for new detail pages. Follow its structure and styling patterns. + +### List Pages - Use Global Styles + +All list pages should use the **unified global styles** from `style.css`: + +- `.page-header` - Header with title and action button +- `.filters` - Search and filter controls +- `.card` - Main content container +- `.table-container` - Scrollable table wrapper +- `table`, `th`, `td` - Table styling +- `.pagination` - Page navigation +- `.badge`, `.badge-success`, etc. - Status badges +- `.actions` - Action button column + +**PrintersList.vue is the Master Template for List Pages** + +Use `PrintersList.vue` as the reference for new list pages. It has NO scoped styles - everything uses global CSS. + +**Only add scoped styles for page-specific elements** (e.g., icon cells for applications, stats badge for knowledge base). + +### Dark Mode Support + +Dark mode is automatic via `@media (prefers-color-scheme: dark)`. Using CSS variables ensures colors adapt automatically - no extra work needed per page. + +## Component Organization + +- **Global styles**: `src/assets/style.css` +- **Page-specific styles**: Scoped ` diff --git a/frontend/src/components/ColorSwatchPicker.vue b/frontend/src/components/ColorSwatchPicker.vue new file mode 100644 index 00000000..ffc197a8 --- /dev/null +++ b/frontend/src/components/ColorSwatchPicker.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/frontend/src/components/CustomFieldsInputs.vue b/frontend/src/components/CustomFieldsInputs.vue new file mode 100644 index 00000000..59ef192b --- /dev/null +++ b/frontend/src/components/CustomFieldsInputs.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/frontend/src/components/CustomFieldsSection.vue b/frontend/src/components/CustomFieldsSection.vue new file mode 100644 index 00000000..f86c7663 --- /dev/null +++ b/frontend/src/components/CustomFieldsSection.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/src/components/EmailReportButton.vue b/frontend/src/components/EmailReportButton.vue new file mode 100644 index 00000000..7a9f3e85 --- /dev/null +++ b/frontend/src/components/EmailReportButton.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/components/EmbeddedLocationMap.vue b/frontend/src/components/EmbeddedLocationMap.vue new file mode 100644 index 00000000..22180a69 --- /dev/null +++ b/frontend/src/components/EmbeddedLocationMap.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/frontend/src/components/EmployeeSearch.vue b/frontend/src/components/EmployeeSearch.vue new file mode 100644 index 00000000..9be6b154 --- /dev/null +++ b/frontend/src/components/EmployeeSearch.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/frontend/src/components/LocationMapTooltip.vue b/frontend/src/components/LocationMapTooltip.vue new file mode 100644 index 00000000..e4532d27 --- /dev/null +++ b/frontend/src/components/LocationMapTooltip.vue @@ -0,0 +1,268 @@ + + + + + + + diff --git a/frontend/src/components/Modal.vue b/frontend/src/components/Modal.vue new file mode 100644 index 00000000..090d976e --- /dev/null +++ b/frontend/src/components/Modal.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/frontend/src/components/PaginationBar.vue b/frontend/src/components/PaginationBar.vue new file mode 100644 index 00000000..6bced812 --- /dev/null +++ b/frontend/src/components/PaginationBar.vue @@ -0,0 +1,83 @@ + + + diff --git a/frontend/src/components/ShopFloorMap.vue b/frontend/src/components/ShopFloorMap.vue new file mode 100644 index 00000000..c6918a1a --- /dev/null +++ b/frontend/src/components/ShopFloorMap.vue @@ -0,0 +1,790 @@ + + + + + diff --git a/frontend/src/components/ToastHost.vue b/frontend/src/components/ToastHost.vue new file mode 100644 index 00000000..74b06614 --- /dev/null +++ b/frontend/src/components/ToastHost.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/components/WarrantyPanel.vue b/frontend/src/components/WarrantyPanel.vue new file mode 100644 index 00000000..f5464044 --- /dev/null +++ b/frontend/src/components/WarrantyPanel.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/frontend/src/composables/enabledPlugins.js b/frontend/src/composables/enabledPlugins.js new file mode 100644 index 00000000..b95e2a39 --- /dev/null +++ b/frontend/src/composables/enabledPlugins.js @@ -0,0 +1,42 @@ +// Enabled-plugin gating for the router. Fetches the enabled plugin-name list +// ONCE (cached promise) so a disabled backend plugin's frontend routes become +// unreachable by direct URL. Fail-open: any fetch error treats every plugin as +// enabled, so an API blip can never brick navigation. +import { pluginsApi } from '../api' + +let enabledPromise = null +let enabledSet = null // resolved Set, or null while pending / on failure + +// Kick off (or reuse) the single fetch. The endpoint is jwt-optional so this +// works on unauthenticated kiosk routes (e.g. /tv) too. +export function loadEnabledPlugins() { + if (!enabledPromise) { + enabledPromise = pluginsApi.enabled() + .then(response => { + const names = response?.data?.data + // Only trust a real array; anything else -> fail open (null). + enabledSet = Array.isArray(names) ? new Set(names) : null + return enabledSet + }) + .catch(() => { + // Fail open: leave enabledSet null so isPluginEnabled returns true. + enabledSet = null + return null + }) + } + return enabledPromise +} + +// True if the plugin is enabled OR the list is unknown (fail-open). A null +// enabledSet means we never got a trustworthy answer, so allow everything. +export function isPluginEnabled(name) { + if (!name) return true + if (enabledSet === null) return true + return enabledSet.has(name) +} + +// Test/hot-reload hook: forget the cached result so the next call refetches. +export function resetEnabledPlugins() { + enabledPromise = null + enabledSet = null +} diff --git a/frontend/src/composables/identifierSettings.js b/frontend/src/composables/identifierSettings.js new file mode 100644 index 00000000..b3864463 --- /dev/null +++ b/frontend/src/composables/identifierSettings.js @@ -0,0 +1,71 @@ +// Per-type enable/disable flags for optional asset identifiers, read from the +// settings table. Keys follow identifier___enabled. A legacy +// global key identifier__enabled is honored as a fallback for older +// installs. Missing = enabled, so a fresh install shows every identifier. +import { reactive } from 'vue' +import { settingsApi } from '../api' + +// scope[name][assettype] = boolean. legacy[name] = boolean (old global flag). +const state = reactive({ + scope: {}, + legacy: {}, + loaded: false +}) + +let inflight = null + +const KEY_RE = /^identifier_(.+?)(?:_(machine|computer|printer|network_device|measuring_tool))?_enabled$/ + +function applySetting(key, value) { + const match = KEY_RE.exec(key) + if (!match) return + const name = match[1] + const assettype = match[2] + if (assettype) { + if (!state.scope[name]) state.scope[name] = {} + state.scope[name][assettype] = value !== false + } else { + state.legacy[name] = value !== false + } +} + +function fetchFlags() { + inflight = settingsApi.list() + .then(({ data }) => { + ;(data.data || []).forEach(s => applySetting(s.key, s.value)) + state.loaded = true + }) + .catch(() => { state.loaded = true }) + .finally(() => { inflight = null }) + return inflight +} + +function loadFlags() { + if (!state.loaded && !inflight) fetchFlags() +} + +// Re-read flags from the server. Call after an identifier setting changes so +// other open views pick it up without a full page reload. +export function reloadIdentifierFlags() { + return fetchFlags() +} + +// Optimistically update one flag in the shared state (e.g. right after a +// Settings toggle) so dependent views react immediately. +export function setIdentifierFlag(name, assettype, enabled) { + applySetting(`identifier_${name}_${assettype}_enabled`, enabled) +} + +// True when identifier `name` should show on `assettype`. Per-type flag wins, +// then the legacy global flag, then default-on. +function isEnabled(name, assettype) { + const perType = state.scope[name] + if (perType && assettype in perType) return perType[assettype] + if (name in state.legacy) return state.legacy[name] + return true +} + +export function useIdentifierFlags() { + loadFlags() + return { state, isEnabled } +} diff --git a/frontend/src/composables/listQuery.js b/frontend/src/composables/listQuery.js new file mode 100644 index 00000000..cee61558 --- /dev/null +++ b/frontend/src/composables/listQuery.js @@ -0,0 +1,102 @@ +import { ref, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' + +// Keep a list page's current page and search term in the URL query so the +// browser Back button restores them. Without this a list keeps page in local +// state and remounts at page 1 after visiting a detail page and going Back. +// +// Usage in a list view: +// const { page, search, setPage, setSearch } = useListQuery({ onChange: loadRows }) +// goToPage(p) -> setPage(p); loadRows() +// debouncedSearch -> setSearch(search.value); loadRows() +// filter reset -> setPage(1); loadRows() +// +// Options: +// onChange callback run when the query changes from outside (Back/Forward, +// deep link) so the list reloads at the restored page. +// extraKeys optional extra query keys to persist (e.g. ['typeid']); each +// gets a ref exposed under the returned `extras` object. +export function useListQuery(options = {}) { + const route = useRoute() + const router = useRouter() + const onChange = options.onChange || (() => {}) + const extraKeys = options.extraKeys || [] + + const page = ref(parseInt(route.query.page, 10) || 1) + const search = ref(route.query.q || '') + + const extras = {} + for (const key of extraKeys) { + extras[key] = ref(route.query[key] || '') + } + + // Build the next query. Keep unrelated keys intact. Write page only when > 1 + // and search only when non-empty so the clean state is a bare path. + function buildQuery() { + const query = { ...route.query } + if (page.value > 1) query.page = String(page.value) + else delete query.page + if (search.value) query.q = search.value + else delete query.q + for (const key of extraKeys) { + if (extras[key].value) query[key] = String(extras[key].value) + else delete query[key] + } + return query + } + + // replace (not push) so paging does not spam history; Back leaves the list. + function syncUrl() { + const query = buildQuery() + const current = route.query + const keys = new Set([...Object.keys(query), ...Object.keys(current)]) + let same = true + for (const key of keys) { + if (String(query[key] ?? '') !== String(current[key] ?? '')) { + same = false + break + } + } + if (!same) router.replace({ query }) + } + + function setPage(newPage) { + page.value = newPage + syncUrl() + } + + // Changing the search resets to page 1. + function setSearch(term) { + search.value = term + page.value = 1 + syncUrl() + } + + // Changing an extra filter resets to page 1. + function setExtra(key, value) { + extras[key].value = value + page.value = 1 + syncUrl() + } + + // Re-sync refs when the query changes from outside (Back/Forward, deep link). + // Fire onChange only when page/search/extras actually changed so a self + // syncUrl() call does not trigger a redundant reload. + watch(() => route.query, (newQuery) => { + const newPage = parseInt(newQuery.page, 10) || 1 + const newSearch = newQuery.q || '' + let changed = false + if (newPage !== page.value) { page.value = newPage; changed = true } + if (newSearch !== search.value) { search.value = newSearch; changed = true } + for (const key of extraKeys) { + const newValue = newQuery[key] || '' + if (newValue !== String(extras[key].value || '')) { + extras[key].value = newValue + changed = true + } + } + if (changed) onChange() + }) + + return { page, search, extras, setPage, setSearch, setExtra } +} diff --git a/frontend/src/composables/mapConfig.js b/frontend/src/composables/mapConfig.js new file mode 100644 index 00000000..5009a5d9 --- /dev/null +++ b/frontend/src/composables/mapConfig.js @@ -0,0 +1,69 @@ +// Facility floor-map blueprint config, read from the settings table so each +// site instance (ADR-004) renders its own floor plan instead of a hardcoded +// one. Keys: map_blueprint_light, map_blueprint_dark, map_width, map_height. +// Missing keys fall back to the generic placeholder so a fresh or offline +// install still renders; each site uploads its own blueprint in Settings. +import { reactive } from 'vue' +import { settingsApi } from '../api' +import { withBase } from '../utils/basePath' + +// Fallback defaults - match the seeded map_blueprint_* setting defaults. +const DEFAULTS = { + blueprintLight: '/static/images/floorplan-placeholder.svg', + blueprintDark: '/static/images/floorplan-placeholder.svg', + width: 3300, + height: 2550 +} + +// Shared reactive config. Import as `state` to read width/height/blueprint. +export const state = reactive({ ...DEFAULTS, loaded: false }) + +let inflight = null + +function applySetting(key, value) { + if (value === null || value === undefined || value === '') return + if (key === 'map_blueprint_light') state.blueprintLight = value + else if (key === 'map_blueprint_dark') state.blueprintDark = value + else if (key === 'map_width') { + const n = parseInt(value, 10) + if (!isNaN(n) && n > 0) state.width = n + } else if (key === 'map_height') { + const n = parseInt(value, 10) + if (!isNaN(n) && n > 0) state.height = n + } +} + +function fetchConfig() { + inflight = settingsApi.list({ category: 'map' }) + .then(({ data }) => { + ;(data.data || []).forEach(s => applySetting(s.key, s.value)) + state.loaded = true + }) + .catch(() => { state.loaded = true }) + .finally(() => { inflight = null }) + return inflight +} + +// Fetch the map config once (shared across all map components). Returns a +// promise that resolves when state is populated, so a caller can await it +// before initializing a Leaflet map that needs the dimensions. +export function loadMapConfig() { + if (state.loaded) return Promise.resolve() + if (inflight) return inflight + return fetchConfig() +} + +// Re-read config from the server after a map setting changes. +export function reloadMapConfig() { + return fetchConfig() +} + +// Blueprint image URL for the given theme ('light' | 'dark'). +export function blueprintUrlFor(theme) { + return withBase(theme === 'light' ? state.blueprintLight : state.blueprintDark) +} + +export function useMapConfig() { + loadMapConfig() + return { state, blueprintUrlFor } +} diff --git a/frontend/src/composables/settingsCatalog.js b/frontend/src/composables/settingsCatalog.js new file mode 100644 index 00000000..81015ff4 --- /dev/null +++ b/frontend/src/composables/settingsCatalog.js @@ -0,0 +1,86 @@ +// Settings catalog: the static core groups (settingsNav.js) merged with the +// plugin-contributed cards from the ADR-010 get_settings_cards hook, served by +// GET /api/pluginui/settings-cards. Both SettingsLayout (rail) and SettingsIndex +// (landing) read the merged catalog so plugins add settings cards with no core +// edit. Fetched once into a module-level ref and shared across callers. +import { ref } from 'vue' +import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets } from 'lucide-vue-next' +import api from '../api' +import { settingsGroups } from '../views/settings/settingsNav' + +// Icon string keys (as returned by the hook) mapped to Lucide components, +// same idea as the sidebar iconMap. Unknown keys fall back to a puzzle piece. +const iconMap = { + ruler: Ruler, + wrench: Wrench, + cog: Cog, + package: Package, + settings: Settings, + puzzle: Puzzle, + sliders: SlidersHorizontal, + palette: Palette, + bell: Bell, + network: Network, + printer: Printer, + droplets: Droplets, +} + +function resolveIcon(key) { + return iconMap[key] || Puzzle +} + +// Merge plugin cards into a fresh copy of the core groups. A card joins the +// group whose title matches its `group`; a new group is appended at the end. +function mergeCards(baseGroups, cards) { + const merged = baseGroups.map(group => ({ + title: group.title, + cards: [...group.cards], + })) + const byTitle = new Map(merged.map(group => [group.title, group])) + + for (const card of cards) { + const entry = { + to: card.to, + icon: resolveIcon(card.icon), + title: card.title, + description: card.description || '', + position: card.position ?? 99, + } + let group = byTitle.get(card.group) + if (!group) { + group = { title: card.group, cards: [] } + byTitle.set(card.group, group) + merged.push(group) + } + group.cards.push(entry) + } + + // Order plugin cards within a group by position; core cards keep their order. + for (const group of merged) { + group.cards.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) + } + return merged +} + +// Shared across component instances: seeded with the core groups, replaced with +// the merged catalog once the hook endpoint answers. +const groups = ref(settingsGroups) +let loaded = false + +async function loadCatalog() { + try { + const response = await api.get('/pluginui/settings-cards') + groups.value = mergeCards(settingsGroups, response.data.data || []) + } catch (error) { + // Degrade to the core-only catalog; the rail still works without plugins. + groups.value = settingsGroups + } +} + +export function useSettingsCatalog() { + if (!loaded) { + loaded = true + loadCatalog() + } + return { groups } +} diff --git a/frontend/src/composables/setupState.js b/frontend/src/composables/setupState.js new file mode 100644 index 00000000..c1e2905e --- /dev/null +++ b/frontend/src/composables/setupState.js @@ -0,0 +1,28 @@ +// Tracks whether first-run setup is complete, so the app can steer a fresh +// admin into the /setup wizard. Defaults to "complete" so the wizard never +// flashes before the real value loads. +import { ref } from 'vue' +import { settingsApi } from '../api' + +export const setupComplete = ref(true) +// Session-only: a fresh admin who clicks "Skip for now" is not nagged again +// until the next login/reload. +export const setupSkipped = ref(false) +const loaded = ref(false) + +export function isSetupLoaded() { + return loaded.value +} + +export async function refreshSetupState() { + try { + const response = await settingsApi.get('setup_complete') + const value = response.data?.data?.value + setupComplete.value = value === true || value === 'true' + } catch (err) { + // If we cannot read it, assume complete so we do not trap the user. + setupComplete.value = true + } + loaded.value = true + return setupComplete.value +} diff --git a/frontend/src/composables/systemSettings.js b/frontend/src/composables/systemSettings.js new file mode 100644 index 00000000..30c339c6 --- /dev/null +++ b/frontend/src/composables/systemSettings.js @@ -0,0 +1,359 @@ +// Shared plumbing for the individual system-settings pages. +// Each settings page calls useSystemSettings() in its own setup, so state is +// per-page (no cross-page leak). The composable owns the load-all/save/toggle/ +// upload machinery plus the static display catalogs, keeping the pages thin. +// Per-page connection-status computeds (zabbix/dell/smtp/saml) stay in the pages. +import { ref, reactive } from 'vue' +import { settingsApi, computersApi } from '../api' +import { setIdentifierFlag } from './identifierSettings' +import { apiError } from '../utils/apiError' + +// Branding logo widgets. kind maps to the backend endpoint; key is the setting +// the resulting URL is stored under. +export const brandingLogos = [ + { kind: 'site', key: 'site_logo', label: 'Site logo', accept: 'image/*', + placeholder: '/ge-aerospace-logo.svg', + hint: 'Shown in the app header and login. Upload an image or type a path/URL.' }, + { kind: 'qr', key: 'qr_logo', label: 'QR overlay logo', accept: 'image/*', + placeholder: '/ge-monogram.svg', + hint: 'Logo overlaid on printed QR codes. Leave blank for no overlay.' }, + { kind: 'badge', key: 'badge_logo', label: 'Machine badge logo', accept: 'image/*', + placeholder: '/ge-aerospace-logo.svg', + hint: 'Logo printed on machine badges. Upload an image or type a path/URL.' }, + { kind: 'favicon', key: 'site_favicon', label: 'Favicon', accept: 'image/*,.ico', + placeholder: '(blank = shipped /favicon.svg)', + hint: 'Browser-tab icon. Leave blank to use the shipped favicon.' }, +] + +// Asset identifier matrix: identifier x asset type. Keys follow +// identifier___enabled. Missing = enabled (default on). +export const identifierRows = [ + { name: 'gaugelabreference', label: 'Gauge Lab Reference' }, + { name: 'maintenancereference', label: 'Maintenance Reference' }, + { name: 'fqdn', label: 'FQDN / Hostname' } +] +export const assetTypeCols = [ + { key: 'machine', label: 'Machine' }, + { key: 'computer', label: 'PC' }, + { key: 'printer', label: 'Printer' }, + { key: 'network_device', label: 'Network' }, + { key: 'measuring_tool', label: 'Measuring Tool' } +] + +// Global-search domain toggles: keys follow search__enabled. +// Missing = enabled (default on). +export const searchDomains = [ + { key: 'application', label: 'Applications' }, + { key: 'knowledgebase', label: 'Knowledge Base' }, + { key: 'employee', label: 'Employees' }, + { key: 'machine', label: 'Machines' }, + { key: 'computer', label: 'PCs' }, + { key: 'printer', label: 'Printers' }, + { key: 'network_device', label: 'Network Devices' }, + { key: 'measuring_tool', label: 'Measuring Tools' }, + { key: 'notification', label: 'Notifications' }, + { key: 'subnet', label: 'Subnets' } +] + +export function useSystemSettings() { + // Every settings key across all pages. A page binds only its own subset. + const settings = reactive({ + // Zabbix + zabbix_enabled: false, + zabbix_url: '', + zabbix_token: '', + // Dell warranty + warranty_dell_enabled: false, + warranty_dell_clientid: '', + warranty_dell_clientsecret: '', + warranty_dell_tokenurl: '', + warranty_dell_apiurl: '', + // ServiceNow + servicenow_enabled: true, + servicenow_search_url: '', + servicenow_ticket_prefixes: '', + servicenow_incident_url: '', + servicenow_change_url: '', + // Branding + site_logo: '', + qr_logo: '', + badge_logo: '', + site_favicon: '', + brand_primary_color: '', + brand_primary_dark_color: '', + brand_accent_color: '', + brand_sidebar_color: '', + // Printing and labels + qr_target_printer: '', + qr_target_usb: '', + usb_label_style: 'barcode', + qr_target_machine: '', + qr_target_computer: '', + qr_target_network_device: '', + qr_target_measuring_tool: '', + label_default_style: 'card', + label_default_codetype: 'qr', + label_default_encodes_machine: 'assetnumber', + label_default_encodes_computer: 'assetpage', + label_default_encodes_printer: 'assetpage', + label_default_encodes_network_device: 'assetpage', + label_default_encodes_measuring_tool: 'location', + // Email + smtp_enabled: false, + smtp_host: '', + smtp_port: 587, + smtp_username: '', + smtp_password: '', + smtp_use_tls: true, + smtp_from_address: '', + smtp_from_name: 'ShopDB', + alert_recipients: '', + // Audit + audit_retention_days: 90, + // Floor map blueprint (per-facility) + map_blueprint_light: '', + map_blueprint_dark: '', + map_width: 3300, + map_height: 2550, + // SAML + saml_enabled: false, + saml_idp_metadata_url: '', + saml_entity_id: '', + saml_acs_url: '', + saml_allow_local_login: true, + saml_auto_create_users: true, + saml_admin_group: '' + }) + + const identifierMatrix = reactive({}) + const searchMatrix = reactive({}) + const pcTypeMappings = ref([]) // [{ pxetype, computertype }] + const computerTypes = ref([]) // ComputerType names for the dropdown + + const loading = ref(true) + const saving = ref(false) + const mapUploading = ref(false) + const brandingUploading = ref(false) + const error = ref('') + const success = ref('') + + function flashSaved() { + success.value = 'Setting saved' + setTimeout(() => { success.value = '' }, 2000) + } + + function identifierKey(name, assettype) { + return `identifier_${name}_${assettype}_enabled` + } + + function matrixValue(name, assettype) { + const key = identifierKey(name, assettype) + return key in identifierMatrix ? identifierMatrix[key] : true + } + + function searchValue(domainKey) { + const key = `search_${domainKey}_enabled` + return key in searchMatrix ? searchMatrix[key] : true + } + + // Load all settings and fan them into settings / matrices / pctype rows. + async function loadSettings() { + try { + loading.value = true + const { data } = await settingsApi.list() + + const pctypeRows = [] + for (const setting of data.data) { + if (setting.key in settings) { + settings[setting.key] = setting.value + } else if (/^identifier_.+_(machine|computer|printer|network_device|measuring_tool)_enabled$/.test(setting.key)) { + identifierMatrix[setting.key] = setting.value !== false + } else if (/^search_.+_enabled$/.test(setting.key)) { + searchMatrix[setting.key] = setting.value !== false + } else if (setting.key.startsWith('pctypemap_')) { + pctypeRows.push({ + pxetype: setting.key.slice('pctypemap_'.length), + computertype: setting.value + }) + } + } + pcTypeMappings.value = pctypeRows.sort((a, b) => a.pxetype.localeCompare(b.pxetype)) + } catch (e) { + error.value = 'Failed to load settings' + console.error(e) + } finally { + loading.value = false + } + } + + // Computer type options for the pctype mapping dropdown (own request). + async function loadComputerTypes() { + try { + const typesResponse = await computersApi.types.list({ perpage: 100 }) + computerTypes.value = (typesResponse.data.data || []).map(t => t.computertype) + } catch (typesError) { + console.error('Failed to load computer types', typesError) + } + } + + async function saveSetting(key, value) { + try { + saving.value = true + error.value = '' + success.value = '' + await settingsApi.update(key, value) + settings[key] = value + flashSaved() + } catch (e) { + error.value = apiError(e, 'Failed to save setting') + console.error(e) + } finally { + saving.value = false + } + } + + async function toggleSetting(key) { + await saveSetting(key, !settings[key]) + } + + // Toggle a per-type identifier flag. The key may not be seeded on older + // installs, so create it when the update returns 404. + async function toggleIdentifier(name, assettype) { + const key = identifierKey(name, assettype) + const newValue = !matrixValue(name, assettype) + try { + saving.value = true + error.value = '' + success.value = '' + try { + await settingsApi.update(key, newValue) + } catch (e) { + if (e.response?.status === 404) { + await settingsApi.create({ + key, + value: newValue, + valuetype: 'boolean', + category: 'identifiers', + description: `Show the ${name} identifier on ${assettype} assets` + }) + } else { + throw e + } + } + identifierMatrix[key] = newValue + // Push into the shared composable so open asset views react without a + // full page reload (the composable otherwise fetches only once). + setIdentifierFlag(name, assettype, newValue) + flashSaved() + } catch (e) { + error.value = apiError(e, 'Failed to save setting') + console.error(e) + } finally { + saving.value = false + } + } + + async function toggleSearchDomain(domainKey) { + const key = `search_${domainKey}_enabled` + const newValue = !searchValue(domainKey) + const label = searchDomains.find(d => d.key === domainKey)?.label || domainKey + try { + saving.value = true + error.value = '' + success.value = '' + try { + await settingsApi.update(key, newValue) + } catch (e) { + if (e.response?.status === 404) { + await settingsApi.create({ + key, + value: newValue, + valuetype: 'boolean', + category: 'search', + description: `Include ${label} in global search results` + }) + } else { + throw e + } + } + searchMatrix[key] = newValue + flashSaved() + } catch (e) { + error.value = apiError(e, 'Failed to save setting') + console.error(e) + } finally { + saving.value = false + } + } + + async function changePcTypeMapping(pxetype, computertype) { + const key = `pctypemap_${pxetype}` + try { + saving.value = true + error.value = '' + success.value = '' + await settingsApi.update(key, computertype) + const row = pcTypeMappings.value.find(r => r.pxetype === pxetype) + if (row) row.computertype = computertype + flashSaved() + } catch (e) { + error.value = apiError(e, 'Failed to save setting') + console.error(e) + } finally { + saving.value = false + } + } + + async function uploadBlueprint(theme, event) { + const file = event.target.files[0] + if (!file) return + mapUploading.value = true + error.value = '' + success.value = '' + try { + const { data } = await settingsApi.uploadMapBlueprint(theme, file) + const url = data?.data?.value + if (theme === 'light') settings.map_blueprint_light = url + else settings.map_blueprint_dark = url + success.value = 'Blueprint uploaded' + setTimeout(() => { success.value = '' }, 2000) + } catch (e) { + error.value = apiError(e, 'Upload failed') + } finally { + mapUploading.value = false + event.target.value = '' + } + } + + async function uploadLogo(kind, key, event) { + const file = event.target.files[0] + if (!file) return + brandingUploading.value = true + error.value = '' + success.value = '' + try { + const { data } = await settingsApi.uploadBrandingLogo(kind, file) + const url = data?.data?.value + if (url) settings[key] = url + success.value = 'Logo uploaded' + setTimeout(() => { success.value = '' }, 2000) + } catch (e) { + error.value = apiError(e, 'Upload failed') + } finally { + brandingUploading.value = false + event.target.value = '' + } + } + + return { + settings, + identifierMatrix, searchMatrix, pcTypeMappings, computerTypes, + loading, saving, mapUploading, brandingUploading, error, success, + loadSettings, loadComputerTypes, + saveSetting, toggleSetting, + identifierKey, matrixValue, toggleIdentifier, + searchValue, toggleSearchDomain, + changePcTypeMapping, + uploadBlueprint, uploadLogo, + } +} diff --git a/frontend/src/composables/toast.js b/frontend/src/composables/toast.js new file mode 100644 index 00000000..6ff949e1 --- /dev/null +++ b/frontend/src/composables/toast.js @@ -0,0 +1,35 @@ +// Global toast notifications. Import useToast() anywhere and call +// toast.success('...') / toast.error('...') / toast.info('...'). A single +// mounted in AppLayout renders the stack. +import { reactive } from 'vue' + +const state = reactive({ + items: [], +}) + +let nextId = 1 + +function dismiss(id) { + const index = state.items.findIndex(t => t.id === id) + if (index !== -1) state.items.splice(index, 1) +} + +function push(message, type = 'info', duration = 5000) { + if (!message) return + const id = nextId++ + state.items.push({ id, message, type }) + if (duration > 0) { + setTimeout(() => dismiss(id), duration) + } + return id +} + +export function useToast() { + return { + items: state.items, + dismiss, + success: (message, duration) => push(message, 'success', duration), + error: (message, duration) => push(message, 'error', duration ?? 8000), + info: (message, duration) => push(message, 'info', duration), + } +} diff --git a/frontend/src/composables/warrantyBadge.js b/frontend/src/composables/warrantyBadge.js new file mode 100644 index 00000000..9fa2b11b --- /dev/null +++ b/frontend/src/composables/warrantyBadge.js @@ -0,0 +1,44 @@ +// Shared warranty fetch + hero-badge derivation for asset detail pages. +// Pass a getter for the asset id; get back the warranties list (to feed a +// WarrantyPanel) and a heroWarranty for a compact status badge. +import { ref, computed, watch } from 'vue' +import { warrantyApi } from '../api' + +const STATUS_RANK = { expired: 0, expiring: 1, active: 2, unknown: 3 } +const STATUS_LABELS = { + active: 'Under Warranty', + expiring: 'Warranty Expiring', + expired: 'Warranty Expired', + unknown: 'Warranty', +} + +export function useWarrantyBadge(getAssetId) { + const warranties = ref([]) + + // Worst-case warranty drives the badge: expired > expiring > active > unknown. + const heroWarranty = computed(() => { + if (!warranties.value.length) return null + const worst = [...warranties.value] + .sort((a, b) => (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9))[0] + return { ...worst, label: STATUS_LABELS[worst.status] || 'Warranty' } + }) + + function warrantyDate(d) { + return d ? new Date(d + 'T00:00:00').toLocaleDateString() : '' + } + + async function load() { + const assetid = getAssetId() + if (!assetid) { warranties.value = []; return } + try { + const response = await warrantyApi.forAsset(assetid) + warranties.value = response.data.data || [] + } catch (err) { + warranties.value = [] + } + } + + watch(getAssetId, load, { immediate: true }) + + return { warranties, heroWarranty, warrantyDate, reloadWarranties: load } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 00000000..739dcfe4 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,21 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import router from './router' +import App from './App.vue' + +// Locally-bundled Inter (air-gap safe, no Google Fonts fetch) +import '@fontsource-variable/inter' + +// Initialize theme on app load +import './stores/theme' +import { applyBranding } from './utils/siteSettings' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) + +app.mount('#app') + +// Apply per-site favicon + brand color once settings load (no-op on defaults). +applyBranding() diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 00000000..ade1a1ec --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,170 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/auth' +import AppLayout from '../views/AppLayout.vue' +import SettingsLayout from '../views/settings/SettingsLayout.vue' +import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState } from '../composables/setupState' +import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins' +import { useToast } from '../composables/toast' + +// Auto-discover all route modules from routes/ directory +const routeModules = import.meta.glob('./routes/*.js', { eager: true }) +const rawChildren = Object.values(routeModules).flatMap(m => m.default) + +// Gather the settings pages (spread across plugin route files) and nest them +// under a single two-pane shell so the grouped rail stays put while the right +// pane swaps. Slides is a sidebar page that happens to live at /settings/slides; +// keep it full-width (not inside the settings rail). +const SETTINGS_STANDALONE = new Set(['settings/slides']) +const settingsChildren = [] +const otherChildren = [] +for (const route of rawChildren) { + const path = route.path + if (path === 'settings') { + // Old index becomes the shell's default child (keeps name 'settings'). + settingsChildren.unshift({ ...route, path: '' }) + } else if (typeof path === 'string' && path.startsWith('settings/') && !SETTINGS_STANDALONE.has(path)) { + settingsChildren.push({ ...route, path: path.replace(/^settings\//, '') }) + } else { + otherChildren.push(route) + } +} +const appChildren = [ + ...otherChildren, + { + path: 'settings', + component: SettingsLayout, + meta: { requiresAuth: true, requiresAdmin: true }, + children: settingsChildren, + }, +] + +const routes = [ + { + path: '/login', + name: 'login', + component: () => import('../views/Login.vue'), + meta: { guest: true } + }, + // Forced/self-service password change (standalone, authenticated) + { + path: '/change-password', + name: 'change-password', + component: () => import('../views/ChangePassword.vue'), + meta: { requiresAuth: true } + }, + // First-run setup wizard (standalone, admin-only) + { + path: '/setup', + name: 'setup', + component: () => import('../views/SetupWizard.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + // Standalone full-screen dashboards (no sidebar, no auth required) + { + path: '/shopfloor', + name: 'shopfloor', + component: () => import('../views/ShopfloorDashboard.vue') + }, + { + path: '/tv', + name: 'tv', + component: () => import('../views/TVDashboard.vue'), + meta: { plugin: 'slides' } + }, + // Print pages (standalone, no sidebar/header) + { + path: '/print/machine-badge/:id', + name: 'print-machine-badge', + component: () => import('../views/print/MachineBadge.vue') + }, + // Shared asset label/code generator for any asset type (public, like the + // other /print/* routes). assettype = machine|computer|printer| + // network_device|measuring_tool; id = the asset's plugin id. + { + path: '/print/asset-label/:assettype/:id', + name: 'print-asset-label', + component: () => import('../views/print/AssetLabel.vue') + }, + // Batch label sheets (ULINE 6-up / mini 72-up) for one asset type. + { + path: '/print/asset-label-batch/:assettype', + name: 'print-asset-label-batch', + component: () => import('../views/print/AssetLabelBatch.vue') + }, + { + path: '/print/printer-qr', + name: 'print-printer-qr-batch', + component: () => import('../views/print/PrinterQRBatch.vue'), + meta: { plugin: 'printers' } + }, + { + path: '/print/printer-qr/:id', + name: 'print-printer-qr-single', + component: () => import('../views/print/PrinterQRSingle.vue'), + meta: { plugin: 'printers' } + }, + { + path: '/print/usb-labels', + name: 'print-usb-labels', + component: () => import('../views/print/USBLabelBatch.vue'), + meta: { plugin: 'usb' } + }, + { + path: '/', + component: AppLayout, + children: appChildren + } +] + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes +}) + +// Navigation guard +router.beforeEach(async (to, from, next) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + // Remember where they were headed so login can send them back there. + return next({ path: '/login', query: { redirect: to.fullPath } }) + } + if (to.meta.requiresAdmin && !authStore.isAdmin) { + return next('/') + } + if (to.meta.guest && authStore.isAuthenticated) { + return next('/') + } + + // Forced password change: an admin-set temporary password must be replaced + // before the user reaches the rest of the app. Let them log out. + if (authStore.isAuthenticated && authStore.mustChangePassword + && to.path !== '/change-password' && to.path !== '/login') { + return next('/change-password') + } + + // Plugin gating: a disabled backend plugin's frontend routes are dead ends. + // The enabled list is fetched once and cached; fail-open on error so a blip + // cannot brick navigation. Works unauthenticated (endpoint is jwt-optional). + if (to.meta.plugin) { + await loadEnabledPlugins() + if (!isPluginEnabled(to.meta.plugin)) { + useToast().info(`The ${to.meta.plugin} feature is not enabled.`) + return next('/') + } + } + + // First-run: steer a fresh admin into the setup wizard (once, until finished). + if (authStore.isAuthenticated && authStore.isAdmin && to.path !== '/setup') { + if (!isSetupLoaded()) { + await refreshSetupState() + } + if (!setupComplete.value && !setupSkipped.value) { + return next('/setup') + } + } + + next() +}) + +export default router diff --git a/frontend/src/router/routes/applications.js b/frontend/src/router/routes/applications.js new file mode 100644 index 00000000..754aacc5 --- /dev/null +++ b/frontend/src/router/routes/applications.js @@ -0,0 +1,27 @@ +/** + * Applications routes (core feature) + */ +export default [ + { + path: 'applications', + name: 'applications', + component: () => import('../../views/applications/ApplicationsList.vue') + }, + { + path: 'applications/new', + name: 'application-new', + component: () => import('../../views/applications/ApplicationForm.vue'), + meta: { requiresAuth: true } + }, + { + path: 'applications/:id', + name: 'application-detail', + component: () => import('../../views/applications/ApplicationDetail.vue') + }, + { + path: 'applications/:id/edit', + name: 'application-edit', + component: () => import('../../views/applications/ApplicationForm.vue'), + meta: { requiresAuth: true } + } +] diff --git a/frontend/src/router/routes/computers.js b/frontend/src/router/routes/computers.js new file mode 100644 index 00000000..90619dee --- /dev/null +++ b/frontend/src/router/routes/computers.js @@ -0,0 +1,48 @@ +/** + * Computers plugin routes + */ +export default [ + { + path: 'pcs', + name: 'pcs', + component: () => import('../../views/pcs/PCsList.vue'), + meta: { plugin: 'computers' } + }, + { + path: 'pcs/new', + name: 'pc-new', + component: () => import('../../views/pcs/PCForm.vue'), + meta: { requiresAuth: true, plugin: 'computers' } + }, + { + path: 'pcs/:id', + name: 'pc-detail', + component: () => import('../../views/pcs/PCDetail.vue'), + meta: { plugin: 'computers' } + }, + { + path: 'pcs/:id/edit', + name: 'pc-edit', + component: () => import('../../views/pcs/PCForm.vue'), + meta: { requiresAuth: true, plugin: 'computers' } + }, + // Computer-specific settings + { + path: 'settings/pctypes', + name: 'pctypes', + component: () => import('../../views/settings/PCTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' } + }, + { + path: 'settings/operatingsystems', + name: 'operatingsystems', + component: () => import('../../views/settings/OperatingSystemsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' } + }, + { + path: 'settings/accessprotocols', + name: 'access-protocols', + component: () => import('../../views/settings/AccessProtocolsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' } + } +] diff --git a/frontend/src/router/routes/core.js b/frontend/src/router/routes/core.js new file mode 100644 index 00000000..34b094d4 --- /dev/null +++ b/frontend/src/router/routes/core.js @@ -0,0 +1,285 @@ +/** + * Core routes - Dashboard, Search, Map, Reports, Settings, Employees + */ +export default [ + { + path: '', + name: 'dashboard', + component: () => import('../../views/Dashboard.vue') + }, + { + path: 'search', + name: 'search', + component: () => import('../../views/SearchResults.vue') + }, + { + path: 'map', + name: 'map', + component: () => import('../../views/MapView.vue') + }, + { + path: 'map/editor', + name: 'map-editor', + component: () => import('../../views/MapEditor.vue'), + meta: { requiresAuth: true } + }, + { + path: 'reports', + name: 'reports', + component: () => import('../../views/reports/ReportsIndex.vue') + }, + { + path: 'reports/pc-relationships', + name: 'report-pc-relationships', + component: () => import('../../views/reports/PCRelationshipsReport.vue'), + meta: { plugin: 'computers' } + }, + { + path: 'reports/toner', + name: 'toner-report', + component: () => import('../../views/reports/TonerReport.vue'), + meta: { plugin: 'printers' } + }, + { + path: 'employees/:sso', + name: 'employee-detail', + component: () => import('../../views/employees/EmployeeDetail.vue'), + meta: { plugin: 'employees' } + }, + // Settings + { + path: 'settings', + name: 'settings', + component: () => import('../../views/settings/SettingsIndex.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/vendors', + name: 'vendors', + component: () => import('../../views/vendors/VendorsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/modeltypes', + name: 'modeltypes', + component: () => import('../../views/settings/ModelTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/locations', + name: 'locations', + component: () => import('../../views/settings/LocationsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/statuses', + name: 'statuses', + component: () => import('../../views/settings/StatusesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/models', + name: 'models', + component: () => import('../../views/settings/ModelsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/businessunits', + name: 'businessunits', + component: () => import('../../views/settings/BusinessUnitsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/supportteams', + name: 'supportteams', + component: () => import('../../views/settings/SupportTeamsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/dashboarddefaults', + name: 'dashboarddefaults', + component: () => import('../../views/settings/DashboardDefaultsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/site', + name: 'site-settings', + component: () => import('../../views/settings/SiteSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/slides', + name: 'slide-manager', + component: () => import('../../views/settings/SlideManager.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'slides' } + }, + { + path: 'settings/machinetypes', + name: 'machinetypes', + component: () => import('../../views/settings/MachineTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/networktypes', + name: 'network-types', + component: () => import('../../views/settings/NetworkTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/printertypes', + name: 'printer-types', + component: () => import('../../views/settings/PrinterTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/relationshiptypes', + name: 'relationship-types', + component: () => import('../../views/settings/RelationshipTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/locationtypes', + name: 'location-types', + component: () => import('../../views/settings/LocationTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/assettypes', + name: 'asset-types', + component: () => import('../../views/settings/AssetTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/customfields', + name: 'custom-fields', + component: () => import('../../views/settings/CustomFieldsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/employeedirectory', + name: 'employee-directory', + component: () => import('../../views/settings/EmployeeDirectory.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + // The old monolithic System Settings tab page is split into one routed page + // per section (below). This redirect keeps old bookmarks working: bare + // /settings/system and every /settings/system?tab= land on the right page. + { + path: 'settings/system', + name: 'system-settings', + redirect: to => { + const tabRedirects = { + integrations: '/settings/servicenow', + branding: '/settings/branding', + printing: '/settings/printing', + email: '/settings/email', + audit: '/settings/audit', + auth: '/settings/authentication', + identifiers: '/settings/assetidentifiers', + search: '/settings/globalsearch', + map: '/settings/floormap', + pctype: '/settings/pctypemapping' + } + // vue-router carries the original ?tab= query onto the target; harmless + // since the new pages ignore it, and the path is what routes the view. + return tabRedirects[to.query.tab] || '/settings/servicenow' + }, + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/servicenow', + name: 'servicenow-settings', + component: () => import('../../views/settings/ServiceNowSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/zabbix', + name: 'zabbix-settings', + component: () => import('../../views/settings/ZabbixSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/dellwarranty', + name: 'dell-warranty-settings', + component: () => import('../../views/settings/DellWarrantySettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/pctypemapping', + name: 'pctype-mapping-settings', + component: () => import('../../views/settings/PCTypeMappingSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/branding', + name: 'branding-settings', + component: () => import('../../views/settings/BrandingSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/printing', + name: 'printing-settings', + component: () => import('../../views/settings/PrintingSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/email', + name: 'email-settings', + component: () => import('../../views/settings/EmailSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/audit', + name: 'audit-settings', + component: () => import('../../views/settings/AuditSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/authentication', + name: 'authentication-settings', + component: () => import('../../views/settings/AuthenticationSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/apitokens', + name: 'apitokens-settings', + component: () => import('../../views/settings/ApiTokensList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/assetidentifiers', + name: 'asset-identifiers-settings', + component: () => import('../../views/settings/AssetIdentifiersSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/globalsearch', + name: 'global-search-settings', + component: () => import('../../views/settings/GlobalSearchSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/floormap', + name: 'floor-map-settings', + component: () => import('../../views/settings/FloorMapSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/plugins', + name: 'plugins', + component: () => import('../../views/settings/PluginsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/auditlogs', + name: 'audit-logs', + component: () => import('../../views/settings/AuditLogs.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/users', + name: 'users', + component: () => import('../../views/settings/UsersList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + } +] diff --git a/frontend/src/router/routes/geenforce.js b/frontend/src/router/routes/geenforce.js new file mode 100644 index 00000000..a3953c2a --- /dev/null +++ b/frontend/src/router/routes/geenforce.js @@ -0,0 +1,30 @@ +/** + * GE-Enforce plugin routes. + * + * A top-level section (not under /settings) - the manifest editor + fleet + * reports are a large operational surface, so they get their own full-width + * shell with tabs. meta.plugin = 'geenforce' so the ADR-009 guard hides the + * section when the plugin is disabled. Admin-only. + */ +export default [ + { + path: 'geenforce', + component: () => import('../../views/geenforce/GeEnforceLayout.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }, + children: [ + { path: '', redirect: '/geenforce/manifests' }, + { + path: 'manifests', + name: 'geenforce-manifests', + component: () => import('../../views/geenforce/ManifestEditor.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' } + }, + { + path: 'reports', + name: 'geenforce-reports', + component: () => import('../../views/geenforce/EnforcementReports.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' } + } + ] + } +] diff --git a/frontend/src/router/routes/knowledgebase.js b/frontend/src/router/routes/knowledgebase.js new file mode 100644 index 00000000..0bd05e3b --- /dev/null +++ b/frontend/src/router/routes/knowledgebase.js @@ -0,0 +1,29 @@ +/** + * Knowledge Base plugin routes + */ +export default [ + { + path: 'knowledgebase', + name: 'knowledgebase', + component: () => import('../../views/knowledgebase/KnowledgeBaseList.vue'), + meta: { plugin: 'knowledgebase' } + }, + { + path: 'knowledgebase/new', + name: 'knowledgebase-new', + component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'), + meta: { requiresAuth: true, plugin: 'knowledgebase' } + }, + { + path: 'knowledgebase/:id', + name: 'knowledgebase-detail', + component: () => import('../../views/knowledgebase/KnowledgeBaseDetail.vue'), + meta: { plugin: 'knowledgebase' } + }, + { + path: 'knowledgebase/:id/edit', + name: 'knowledgebase-edit', + component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'), + meta: { requiresAuth: true, plugin: 'knowledgebase' } + } +] diff --git a/frontend/src/router/routes/machines.js b/frontend/src/router/routes/machines.js new file mode 100644 index 00000000..a3e46602 --- /dev/null +++ b/frontend/src/router/routes/machines.js @@ -0,0 +1,29 @@ +/** + * Machines plugin routes + */ +export default [ + { + path: 'machines', + name: 'machines', + component: () => import('../../views/machines/MachinesList.vue'), + meta: { plugin: 'machines' } + }, + { + path: 'machines/new', + name: 'machine-new', + component: () => import('../../views/machines/MachineForm.vue'), + meta: { requiresAuth: true, plugin: 'machines' } + }, + { + path: 'machines/:id', + name: 'machine-detail', + component: () => import('../../views/machines/MachineDetail.vue'), + meta: { plugin: 'machines' } + }, + { + path: 'machines/:id/edit', + name: 'machine-edit', + component: () => import('../../views/machines/MachineForm.vue'), + meta: { requiresAuth: true, plugin: 'machines' } + } +] diff --git a/frontend/src/router/routes/measuringtools.js b/frontend/src/router/routes/measuringtools.js new file mode 100644 index 00000000..fb609170 --- /dev/null +++ b/frontend/src/router/routes/measuringtools.js @@ -0,0 +1,54 @@ +/** + * Measuring-tools plugin routes. + * + * Every route carries meta.plugin = 'measuringtools' so the ADR-009 router + * guard redirects to the dashboard when the backend plugin is disabled. List + * and detail are public (no requiresAuth, matching the other asset types); the + * form requires auth; the settings subtype page requires admin. + */ +export default [ + { + path: 'measuringtools', + name: 'measuringtools', + component: () => import('../../views/measuringtools/MeasuringToolsList.vue'), + meta: { plugin: 'measuringtools' } + }, + { + path: 'measuringtools/new', + name: 'measuringtool-new', + component: () => import('../../views/measuringtools/MeasuringToolForm.vue'), + meta: { requiresAuth: true, plugin: 'measuringtools' } + }, + { + // Resolve a tool from its core asset id (search rows / cross-links carry + // assetid, not the extension id). Shares the detail component. + path: 'measuringtools/by-asset/:assetid', + name: 'measuringtool-by-asset', + component: () => import('../../views/measuringtools/MeasuringToolDetail.vue'), + meta: { plugin: 'measuringtools' } + }, + { + path: 'measuringtools/:id', + name: 'measuringtool-detail', + component: () => import('../../views/measuringtools/MeasuringToolDetail.vue'), + meta: { plugin: 'measuringtools' } + }, + { + path: 'measuringtools/:id/edit', + name: 'measuringtool-edit', + component: () => import('../../views/measuringtools/MeasuringToolForm.vue'), + meta: { requiresAuth: true, plugin: 'measuringtools' } + }, + { + path: 'reports/calibration', + name: 'calibration-report', + component: () => import('../../views/reports/CalibrationReport.vue'), + meta: { plugin: 'measuringtools' } + }, + { + path: 'settings/measuringtooltypes', + name: 'measuringtooltypes', + component: () => import('../../views/settings/MeasuringToolTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'measuringtools' } + } +] diff --git a/frontend/src/router/routes/network.js b/frontend/src/router/routes/network.js new file mode 100644 index 00000000..c721ac76 --- /dev/null +++ b/frontend/src/router/routes/network.js @@ -0,0 +1,53 @@ +/** + * Network plugin routes + */ +export default [ + { + path: 'network', + name: 'network', + component: () => import('../../views/network/NetworkHub.vue'), + meta: { plugin: 'network' } + }, + { + path: 'network/new', + name: 'network-new', + component: () => import('../../views/network/NetworkDeviceForm.vue'), + meta: { requiresAuth: true, plugin: 'network' } + }, + { + path: 'network/:id', + name: 'network-detail', + component: () => import('../../views/network/NetworkDeviceDetail.vue'), + meta: { plugin: 'network' } + }, + { + path: 'network/:id/edit', + name: 'network-edit', + component: () => import('../../views/network/NetworkDeviceForm.vue'), + meta: { requiresAuth: true, plugin: 'network' } + }, + { + // Legacy path -> the Networks tab of the hub. + path: 'networks', + redirect: { path: '/network', query: { tab: 'networks' } } + }, + { + path: 'networks/:id', + name: 'network-subnet-detail', + component: () => import('../../views/network/SubnetDetail.vue'), + meta: { plugin: 'network' } + }, + // Network-specific settings + { + path: 'settings/vlans', + name: 'vlans', + component: () => import('../../views/settings/VLANsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' } + }, + { + path: 'settings/subnets', + name: 'subnets', + component: () => import('../../views/settings/SubnetsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' } + } +] diff --git a/frontend/src/router/routes/notifications.js b/frontend/src/router/routes/notifications.js new file mode 100644 index 00000000..4a267a98 --- /dev/null +++ b/frontend/src/router/routes/notifications.js @@ -0,0 +1,41 @@ +/** + * Notifications plugin routes + */ +export default [ + { + path: 'notifications', + name: 'notifications', + component: () => import('../../views/notifications/NotificationsList.vue'), + meta: { plugin: 'notifications' } + }, + { + path: 'notifications/new', + name: 'notification-new', + component: () => import('../../views/notifications/NotificationForm.vue'), + meta: { requiresAuth: true, plugin: 'notifications' } + }, + { + path: 'settings/notificationtypes', + name: 'notification-types', + component: () => import('../../views/notifications/NotificationTypesList.vue'), + meta: { requiresAuth: true, plugin: 'notifications' } + }, + { + path: 'notifications/:id', + name: 'notification-detail', + component: () => import('../../views/notifications/NotificationForm.vue'), + meta: { plugin: 'notifications' } + }, + { + path: 'notifications/:id/edit', + name: 'notification-edit', + component: () => import('../../views/notifications/NotificationForm.vue'), + meta: { requiresAuth: true, plugin: 'notifications' } + }, + { + path: 'calendar', + name: 'calendar', + component: () => import('../../views/CalendarView.vue'), + meta: { plugin: 'notifications' } + } +] diff --git a/frontend/src/router/routes/printers.js b/frontend/src/router/routes/printers.js new file mode 100644 index 00000000..5bb001de --- /dev/null +++ b/frontend/src/router/routes/printers.js @@ -0,0 +1,42 @@ +/** + * Printers plugin routes + */ +export default [ + { + path: 'printers', + name: 'printers', + component: () => import('../../views/printers/PrintersList.vue'), + meta: { plugin: 'printers' } + }, + { + path: 'printers/new', + name: 'printer-new', + component: () => import('../../views/printers/PrinterForm.vue'), + meta: { requiresAuth: true, plugin: 'printers' } + }, + { + path: 'printers/:id', + name: 'printer-detail', + component: () => import('../../views/printers/PrinterDetail.vue'), + meta: { plugin: 'printers' } + }, + { + path: 'printers/:id/edit', + name: 'printer-edit', + component: () => import('../../views/printers/PrinterForm.vue'), + meta: { requiresAuth: true, plugin: 'printers' } + }, + // printer-specific settings + { + path: 'settings/modelsupplies', + name: 'model-supplies', + component: () => import('../../views/settings/ModelSuppliesList.vue'), + meta: { requiresAuth: true, plugin: 'printers' } + }, + { + path: 'settings/printerdrivers', + name: 'printer-drivers', + component: () => import('../../views/settings/PrinterDriversList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' } + } +] diff --git a/frontend/src/router/routes/usb.js b/frontend/src/router/routes/usb.js new file mode 100644 index 00000000..3558327b --- /dev/null +++ b/frontend/src/router/routes/usb.js @@ -0,0 +1,29 @@ +/** + * USB plugin routes + */ +export default [ + { + path: 'usb', + name: 'usb', + component: () => import('../../views/usb/USBList.vue'), + meta: { plugin: 'usb' } + }, + { + path: 'usb/new', + name: 'usb-new', + component: () => import('../../views/usb/USBForm.vue'), + meta: { requiresAuth: true, plugin: 'usb' } + }, + { + path: 'usb/:id', + name: 'usb-detail', + component: () => import('../../views/usb/USBDetail.vue'), + meta: { plugin: 'usb' } + }, + { + path: 'usb/:id/edit', + name: 'usb-edit', + component: () => import('../../views/usb/USBForm.vue'), + meta: { requiresAuth: true, plugin: 'usb' } + } +] diff --git a/frontend/src/router/routes/warranty.js b/frontend/src/router/routes/warranty.js new file mode 100644 index 00000000..781fcf6d --- /dev/null +++ b/frontend/src/router/routes/warranty.js @@ -0,0 +1,17 @@ +/** + * Warranty plugin routes + */ +export default [ + { + path: 'warranties', + name: 'warranties', + component: () => import('../../views/warranty/WarrantiesList.vue'), + meta: { plugin: 'warranty' } + }, + { + path: 'reports/warranty', + name: 'warranty-report', + component: () => import('../../views/reports/WarrantyReport.vue'), + meta: { plugin: 'warranty' } + } +] diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 00000000..69a54a79 --- /dev/null +++ b/frontend/src/stores/auth.js @@ -0,0 +1,99 @@ +import { defineStore } from 'pinia' +import { authApi, employeesApi } from '../api' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + user: JSON.parse(localStorage.getItem('user') || 'null'), + token: localStorage.getItem('token') || null + }), + + getters: { + isAuthenticated: (state) => !!state.token, + username: (state) => state.user?.username || '', + roles: (state) => state.user?.roles || [], + hasRole: (state) => (role) => state.user?.roles?.includes(role) || false, + isAdmin: (state) => state.user?.roles?.includes('admin') || false, + // True when an admin-set temporary password must be changed before use. + mustChangePassword: (state) => !!state.user?.mustchangepassword, + // Full name from the employee directory (falls back to username/SSO). + displayName: (state) => state.user?.directoryname || state.user?.username || '', + // Employee photo URL if the directory has one for this SSO. The directory + // resolver already returns a usable URL (self-hosted upload or external HR + // path), so it is used as-is. + avatarUrl: (state) => state.user?.directoryphotourl || null + }, + + actions: { + async login(username, password) { + try { + const response = await authApi.login(username, password) + const { access_token, refresh_token, user } = response.data.data + + this.token = access_token + this.user = user + + this.token = access_token + localStorage.setItem('token', access_token) + localStorage.setItem('refreshToken', refresh_token) + await this.enrichFromDirectory() + + return { success: true } + } catch (error) { + const message = error.response?.data?.message || 'Login failed' + return { success: false, message } + } + }, + + async logout() { + try { + await authApi.logout() + } catch (e) { + // Ignore logout errors + } + + this.token = null + this.user = null + + localStorage.removeItem('token') + localStorage.removeItem('refreshToken') + localStorage.removeItem('user') + }, + + // Clear the forced-password-change flag after a successful change so the + // router guard stops steering the user to the change-password view. + clearMustChangePassword() { + if (this.user) { + this.user.mustchangepassword = false + localStorage.setItem('user', JSON.stringify(this.user)) + } + }, + + async fetchUser() { + try { + const response = await authApi.me() + this.user = response.data.data + await this.enrichFromDirectory() + } catch (error) { + this.logout() + } + }, + + // Pull the logged-in user's full name + photo from the employee directory + // by their SSO (= username). Best-effort: skipped/ignored when the employees + // plugin is off, the SSO is not in the directory, or the lookup fails. + async enrichFromDirectory() { + const sso = this.user?.username + if (sso && /^\d+$/.test(sso)) { + try { + const response = await employeesApi.lookup(sso) + const emp = response.data?.data + if (emp) { + this.user.directoryname = `${emp.First_Name || ''} ${emp.Last_Name || ''}`.trim() || null + this.user.directoryphotourl = emp.photourl || null + } + } catch (err) { /* directory unavailable - fall back to username */ } + } + localStorage.setItem('user', JSON.stringify(this.user)) + } + } +}) diff --git a/frontend/src/stores/theme.js b/frontend/src/stores/theme.js new file mode 100644 index 00000000..090190a6 --- /dev/null +++ b/frontend/src/stores/theme.js @@ -0,0 +1,39 @@ +import { ref, watch } from 'vue' + +const STORAGE_KEY = 'shopdb-theme' + +// Get initial theme from localStorage or system preference +function getInitialTheme() { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) return stored + + // Fall back to system preference + if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark' + } + return 'light' +} + +export const currentTheme = ref(getInitialTheme()) + +// Apply theme to document +function applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme) + localStorage.setItem(STORAGE_KEY, theme) +} + +// Initialize on load +applyTheme(currentTheme.value) + +// Watch for changes +watch(currentTheme, (newTheme) => { + applyTheme(newTheme) +}) + +export function toggleTheme() { + currentTheme.value = currentTheme.value === 'dark' ? 'light' : 'dark' +} + +export function setTheme(theme) { + currentTheme.value = theme +} diff --git a/frontend/src/utils/apiError.js b/frontend/src/utils/apiError.js new file mode 100644 index 00000000..0a3ed65d --- /dev/null +++ b/frontend/src/utils/apiError.js @@ -0,0 +1,9 @@ +// Extract a human-readable message from an API error. The backend nests the +// message at data.data.error.message (see error_response); older call sites read +// shallower paths, so try them all before falling back. +export function apiError(err, fallback = 'Something went wrong') { + return err?.response?.data?.data?.error?.message + || err?.response?.data?.error?.message + || err?.response?.data?.message + || fallback +} diff --git a/frontend/src/utils/assetTypes.js b/frontend/src/utils/assetTypes.js new file mode 100644 index 00000000..79b62d4b --- /dev/null +++ b/frontend/src/utils/assetTypes.js @@ -0,0 +1,63 @@ +// Single source of truth for asset-type display labels + detail routing. +// The map and shopfloor views used to each hardcode these identical maps. +// Keys are lowercase; both 'network_device' and 'network device' are accepted +// because the API sends the underscore form and some map data the spaced form. + +const ASSET_TYPE_LABELS = { + 'machine': 'Machines', + 'computer': 'Computers', + 'printer': 'Printers', + 'network_device': 'Network Devices', + 'network device': 'Network Devices', + 'measuring_tool': 'Measuring Tools', +} + +const ASSET_TYPE_ROUTES = { + 'machine': '/machines', + 'computer': '/pcs', + 'printer': '/printers', + 'network_device': '/network', + 'network device': '/network', + 'measuring_tool': '/measuringtools', +} + +// Plugin-specific id field inside asset.typedata for each asset type. +const ASSET_TYPE_ID_KEYS = { + 'machine': 'machineid', + 'computer': 'computerid', + 'printer': 'printerid', + 'network_device': 'networkdeviceid', + 'network device': 'networkdeviceid', + 'measuring_tool': 'measuringtoolid', +} + +function titleCase(text) { + return String(text || '') + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()) +} + +// Plural, human-friendly label for an asset type. Falls back to title-cased +// input so an unknown/new type still reads sensibly. +export function assetTypeLabel(type) { + if (!type) return type + return ASSET_TYPE_LABELS[String(type).toLowerCase()] || titleCase(type) +} + +// Base list route for an asset type (e.g. 'computer' -> '/pcs'). +export function assetTypeRoute(type) { + return ASSET_TYPE_ROUTES[String(type || '').toLowerCase()] || '/machines' +} + +// Full detail route for a unified-format asset, preferring the plugin-specific +// id in typedata and falling back to the asset id. +export function assetDetailRoute(asset) { + const type = (asset.assettype || '').toLowerCase() + const base = assetTypeRoute(type) + const idKey = ASSET_TYPE_ID_KEYS[type] + let id = asset.assetid + if (asset.typedata && idKey && asset.typedata[idKey]) { + id = asset.typedata[idKey] + } + return `${base}/${id}` +} diff --git a/frontend/src/utils/basePath.js b/frontend/src/utils/basePath.js new file mode 100644 index 00000000..c8e6318a --- /dev/null +++ b/frontend/src/utils/basePath.js @@ -0,0 +1,13 @@ +// Single source for the app's mount path. Vite injects import.meta.env.BASE_URL +// from the build-time `base` (default '/', or e.g. '/ops/' for a subpath IIS +// mount). Every root-absolute URL to a Flask-served asset or route must go +// through withBase() so it resolves under the mount instead of the server root. +export const BASE_URL = import.meta.env.BASE_URL + +// Prefix a root-absolute path (e.g. '/ge-aerospace-logo.svg', '/api', '/tv') +// with the mount base. Leaves full URLs (http, data:) untouched. +export function withBase(path) { + if (!path) return path + if (/^([a-z]+:)?\/\//i.test(path) || path.startsWith('data:')) return path + return BASE_URL + String(path).replace(/^\//, '') +} diff --git a/frontend/src/utils/colorStyle.js b/frontend/src/utils/colorStyle.js new file mode 100644 index 00000000..e2b76c2c --- /dev/null +++ b/frontend/src/utils/colorStyle.js @@ -0,0 +1,39 @@ +// Data-driven badge colors. A record stores a hex color; the UI renders it with +// an auto-picked readable text color, so any color stays legible. Pickers should +// offer PALETTE (curated, distinct, accessible) with custom hex as a fallback. + +// Curated categorical palette - distinct + accessible. ~12 is the practical +// ceiling for at-a-glance distinguishability, which is plenty per category. +export const PALETTE = [ + '#f5365c', // red + '#fb6340', // orange + '#ff8800', // amber + '#ffc107', // gold + '#2dce89', // green + '#04b962', // emerald + '#11cdef', // cyan + '#14abef', // blue + '#0d6efd', // royal blue + '#7934f3', // purple + '#e83e8c', // pink + '#6c757d', // gray +] + +// Black or white text for a given background, by perceived brightness. +export function readableText(bg) { + if (!bg || typeof bg !== 'string') return '#ffffff' + let hex = bg.trim().replace('#', '') + if (hex.length === 3) hex = hex.split('').map(c => c + c).join('') + if (hex.length !== 6) return '#ffffff' + const r = parseInt(hex.slice(0, 2), 16) + const g = parseInt(hex.slice(2, 4), 16) + const b = parseInt(hex.slice(4, 6), 16) + const brightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + return brightness > 0.6 ? '#1a1a1a' : '#ffffff' +} + +// Style object for a badge/pill from a stored color (with a neutral fallback). +export function colorStyle(color, fallback = '#6c757d') { + const backgroundColor = color || fallback + return { backgroundColor, color: readableText(backgroundColor) } +} diff --git a/frontend/src/utils/mapColors.js b/frontend/src/utils/mapColors.js new file mode 100644 index 00000000..230aedb0 --- /dev/null +++ b/frontend/src/utils/mapColors.js @@ -0,0 +1,54 @@ +// Marker color logic for the shop-floor map, shared so the PDF export renders +// the exact same colors the map shows on screen. Mirrors the maps defined in +// ShopFloorMap.vue - keep the two in sync if the palette changes. + +export const assetTypeColorsMap = { + machine: '#F44336', // Red + computer: '#2196F3', // Blue + printer: '#4CAF50', // Green + 'network device': '#FF9800', // Orange + network_device: '#FF9800', // Orange (alternate key) + measuring_tool: '#9C27B0', // Purple + 'measuring tool': '#9C27B0' // Purple (normalized key) +} + +const DEFAULT_COLOR = '#BDBDBD' + +// Canonical form for comparing asset-type strings. The API sends the machine +// type as 'network_device' but subtype keys and labels use 'network device', +// so normalize underscores to spaces before any comparison. Without this, +// network-device subtypes silently fail to match (machine/computer/printer +// are single words and were unaffected, which is why only network broke). +export function normalizeAssetType(assettype) { + return (assettype || '').toLowerCase().replace(/_/g, ' ') +} + +// Color for an asset by its top-level asset type (used when no type filter is +// active, so every type is distinguished by color). +export function getAssetTypeColor(assettype) { + if (!assettype) return DEFAULT_COLOR + return assetTypeColorsMap[assettype.toLowerCase()] || DEFAULT_COLOR +} + +// The subtype id for an asset, read from the plugin-specific typedata block. +// Returns null when the asset has no subtype. +export function getSubtypeId(asset) { + if (!asset || !asset.typedata) return null + const typeLower = normalizeAssetType(asset.assettype) + if (typeLower === 'machine') return asset.typedata.machinetypeid + if (typeLower === 'computer') return asset.typedata.computertypeid + if (typeLower === 'network device') return asset.typedata.networkdevicetypeid + if (typeLower === 'printer') return asset.typedata.printertypeid + if (typeLower === 'measuring tool') return asset.typedata.measuringtooltypeid + return null +} + +// Resolve the marker color for an asset the same way ShopFloorMap does: when a +// type is selected, color by subtype (grey fallback); otherwise by asset type. +export function resolveMarkerColor(asset, { selectedType, subtypeColors = {} }) { + if (selectedType) { + const id = getSubtypeId(asset) + return (id != null && subtypeColors[id]) || DEFAULT_COLOR + } + return getAssetTypeColor(asset.assettype) +} diff --git a/frontend/src/utils/mapPdf.js b/frontend/src/utils/mapPdf.js new file mode 100644 index 00000000..8601eac8 --- /dev/null +++ b/frontend/src/utils/mapPdf.js @@ -0,0 +1,135 @@ +// Export the shop-floor map (current filtered assets on the facility blueprint) +// to a PDF, entirely client-side. The blueprint image is drawn full-size and +// each visible marker is placed at its scaled coordinate, so the PDF is a crisp +// vector-over-raster page rather than a screen capture. +import { jsPDF } from 'jspdf' +import { resolveMarkerColor, getSubtypeId, getAssetTypeColor } from './mapColors' + +function loadImage(url) { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = () => resolve(img) + img.onerror = () => reject(new Error('Failed to load blueprint image: ' + url)) + img.src = url + }) +} + +function hexToRgb(hex) { + const h = (hex || '#BDBDBD').replace('#', '') + const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h + return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)] +} + +// Build the legend entries (color + label) for the assets present, matching the +// active coloring mode. +function buildLegend(assets, { selectedType, subtypeColors, subtypeNames }) { + const seen = new Map() + for (const a of assets) { + let key, label, color + if (selectedType) { + const id = getSubtypeId(a) + key = id != null ? String(id) : 'none' + label = (id != null && subtypeNames[id]) || 'Unspecified' + color = (id != null && subtypeColors[id]) || '#BDBDBD' + } else { + key = (a.assettype || 'unknown').toLowerCase() + label = a.assettype || 'Unknown' + color = getAssetTypeColor(a.assettype) + } + if (!seen.has(key)) seen.set(key, { label, color, count: 0 }) + seen.get(key).count += 1 + } + return [...seen.values()].sort((a, b) => b.count - a.count) +} + +export async function exportMapPdf(opts) { + const { + assets = [], + blueprintUrl, + mapWidth = 3300, + mapHeight = 2550, + selectedType = '', + subtypeColors = {}, + subtypeNames = {}, + filters = [], + facility = '', + title = 'Shop Floor Map' + } = opts + + const img = await loadImage(blueprintUrl) + + const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4', compress: true }) + const pageW = doc.internal.pageSize.getWidth() + const pageH = doc.internal.pageSize.getHeight() + const margin = 28 + + // ---- Header ---- + doc.setTextColor('#111111') + doc.setFont('helvetica', 'bold') + doc.setFontSize(16) + doc.text(title, margin, margin + 6) + + doc.setFont('helvetica', 'normal') + doc.setFontSize(9) + doc.setTextColor('#555555') + const stamp = new Date().toLocaleString() + const subParts = [facility, stamp, `${assets.length} asset${assets.length === 1 ? '' : 's'}`].filter(Boolean) + doc.text(subParts.join(' | '), margin, margin + 22) + + const filterText = filters.length ? 'Filters: ' + filters.join(' ') : 'Filters: none (all assets)' + doc.text(filterText, margin, margin + 35) + + // ---- Legend (wraps under the header, pushes the map down) ---- + const legend = buildLegend(assets, { selectedType, subtypeColors, subtypeNames }) + const swatch = 8 + const legendY0 = margin + 50 + const lineH = 15 + let lx = margin + let ly = legendY0 + doc.setFontSize(8.5) + for (const entry of legend) { + const label = `${entry.label} (${entry.count})` + const w = swatch + 4 + doc.getTextWidth(label) + 16 + if (lx + w > pageW - margin) { lx = margin; ly += lineH } + const [r, g, b] = hexToRgb(entry.color) + doc.setFillColor(r, g, b) + doc.setDrawColor('#ffffff'); doc.setLineWidth(0.4) + doc.rect(lx, ly - swatch + 1, swatch, swatch, 'F') + doc.setTextColor('#333333') + doc.text(label, lx + swatch + 4, ly) + lx += w + } + const legendBottom = legend.length ? ly + 6 : legendY0 + + // ---- Blueprint image, fit into the area below the legend ---- + const aspect = mapWidth / mapHeight + const contentTop = legendBottom + 8 + const availW = pageW - margin * 2 + const availH = pageH - contentTop - margin + let imgW = availW + let imgH = availW / aspect + if (imgH > availH) { imgH = availH; imgW = availH * aspect } + const imgX = margin + (availW - imgW) / 2 + const imgY = contentTop + (availH - imgH) / 2 + + doc.addImage(img, 'PNG', imgX, imgY, imgW, imgH, undefined, 'FAST') + doc.setDrawColor('#cccccc'); doc.setLineWidth(0.5) + doc.rect(imgX, imgY, imgW, imgH) + + // ---- Markers (database Y is top-down, same origin as the drawn image) ---- + const radius = 3.2 + for (const a of assets) { + if (a.mapx == null || a.mapy == null) continue + const x = imgX + (a.mapx / mapWidth) * imgW + const y = imgY + (a.mapy / mapHeight) * imgH + if (x < imgX || x > imgX + imgW || y < imgY || y > imgY + imgH) continue + const [r, g, b] = hexToRgb(resolveMarkerColor(a, { selectedType, subtypeColors })) + doc.setFillColor(r, g, b) + doc.setDrawColor('#ffffff'); doc.setLineWidth(0.6) + doc.circle(x, y, radius, 'FD') + } + + const dateSlug = new Date().toISOString().slice(0, 10) + doc.save(`shopfloor-map-${dateSlug}.pdf`) +} diff --git a/frontend/src/utils/qrTarget.js b/frontend/src/utils/qrTarget.js new file mode 100644 index 00000000..688e198b --- /dev/null +++ b/frontend/src/utils/qrTarget.js @@ -0,0 +1,26 @@ +// Shared QR target resolution for printed labels. Each label surface has a +// qr_target_ setting: blank means the QR links to the asset's own +// detail page on this instance; a non-blank value is treated as a URL +// template and every {placeholder} is substituted from the tokens map. +import { getSetting, getSiteBaseUrl } from './siteSettings' + +// Substitute {name} placeholders. Values are URL-encoded; unknown or empty +// placeholders collapse to an empty string rather than leaking the braces. +export function fillUrlTemplate(template, tokens) { + return template.replace(/\{([a-z0-9_]+)\}/gi, (match, name) => { + const value = tokens[name] + return (value === undefined || value === null) ? '' : encodeURIComponent(String(value)) + }) +} + +// Resolve the QR URL for one asset. settingKey is the qr_target_ +// setting, defaultPath the in-app detail route (e.g. /printers/12), tokens +// the placeholder values available to a custom template. +export async function buildQrUrl(settingKey, defaultPath, tokens = {}) { + const template = (await getSetting(settingKey, '')).trim() + if (!template) { + const baseUrl = await getSiteBaseUrl() + return `${baseUrl}${defaultPath}` + } + return fillUrlTemplate(template, tokens) +} diff --git a/frontend/src/utils/siteSettings.js b/frontend/src/utils/siteSettings.js new file mode 100644 index 00000000..22180db2 --- /dev/null +++ b/frontend/src/utils/siteSettings.js @@ -0,0 +1,169 @@ +// Shared read-through for public site settings (site_base_url, facility_name). +// The settings GET is public (jwt optional), so the kiosk dashboard and the +// print views can read these without auth. Fetched once and cached per page. +import { settingsApi } from '@/api' +import { withBase } from '@/utils/basePath' + +let settingsCache = null + +async function loadSettings() { + if (settingsCache) return settingsCache + try { + const response = await settingsApi.list() + const items = response.data?.data || response.data || [] + settingsCache = {} + for (const s of items) settingsCache[s.key] = s.value + } catch (err) { + console.error('Error loading site settings:', err) + settingsCache = {} + } + return settingsCache +} + +export async function getSetting(key, fallback = '') { + const settings = await loadSettings() + const value = settings[key] + return (value === undefined || value === null || value === '') ? fallback : value +} + +// Public base URL for QR codes / absolute links. Falls back to the current +// browsing origin when a site has not set one. +export async function getSiteBaseUrl() { + return getSetting('site_base_url', window.location.origin) +} + +// Facility name shown on the shopfloor dashboard. +export async function getFacilityName() { + return getSetting('facility_name', 'ShopDB') +} + +// Main site logo (sidebar, login, dashboard header). Fallback = shipped GE mark. +export async function getSiteLogo() { + return withBase(await getSetting('site_logo', '/ge-aerospace-logo.svg')) +} + +// Logo composited into the center of printer QR codes. Empty = no overlay. +// Note: '' is a valid "no overlay" value, so read the raw setting rather than +// getSetting (which swaps '' for the fallback). +export async function getQrLogo() { + const settings = await loadSettings() + const value = settings['qr_logo'] + return withBase((value === undefined || value === null) ? '/ge-monogram.svg' : value) +} + +// Logo printed on machine inspection badges. +export async function getBadgeLogo() { + return withBase(await getSetting('badge_logo', '/ge-aerospace-logo.svg')) +} + +// Browser-tab favicon. Empty = keep the shipped /favicon.svg. +export async function getFavicon() { + const value = await getSetting('site_favicon', '') + return value ? withBase(value) : value +} + +// Brand primary color override. Empty = built-in palette from style.css. +export async function getBrandPrimaryColor() { + return getSetting('brand_primary_color', '') +} + +// Primary hover/active color (--primary-dark). Empty = derive from primary. +export async function getBrandPrimaryDarkColor() { + return getSetting('brand_primary_dark_color', '') +} + +// Accent color (--secondary). Empty = built-in palette from style.css. +export async function getBrandAccentColor() { + return getSetting('brand_accent_color', '') +} + +// Sidebar background color (--sidebar-bg). Empty = built-in palette. +export async function getBrandSidebarColor() { + return getSetting('brand_sidebar_color', '') +} + +// Darken a #rrggbb hex color by the given fraction (0.15 = 15% darker). +// Returns null for anything that is not a 6-digit hex so callers can skip it. +function darkenHexColor(hex, fraction) { + const match = /^#([0-9a-fA-F]{6})$/.exec(hex) + if (!match) return null + const num = parseInt(match[1], 16) + const scale = 1 - fraction + const red = Math.round(((num >> 16) & 0xff) * scale) + const green = Math.round(((num >> 8) & 0xff) * scale) + const blue = Math.round((num & 0xff) * scale) + const toHex = (channel) => channel.toString(16).padStart(2, '0') + return `#${toHex(red)}${toHex(green)}${toHex(blue)}` +} + +// ServiceNow ticket-link config. Returns the enabled flag, incident/change URL +// templates, and the global-search URL template (all with a {ticket} +// placeholder). Defaults mirror the previously-hardcoded GE ServiceNow URLs. +export async function getServicenowUrls() { + const enabledRaw = await getSetting('servicenow_enabled', 'true') + const enabled = enabledRaw !== 'false' && enabledRaw !== '0' && enabledRaw !== false + const incidentUrl = await getSetting( + 'servicenow_incident_url', + 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/' + ) + const changeUrl = await getSetting( + 'servicenow_change_url', + 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/' + ) + const searchUrl = await getSetting( + 'servicenow_search_url', + 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/' + ) + return { enabled, incidentUrl, changeUrl, searchUrl } +} + +// Printer hostname template. {ip} is replaced with the dash-separated IP. +export async function getPrinterHostnameTemplate() { + return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net') +} + +// Email domain appended to a contact SSO to build email/Teams links +// (sso@domain). Unset falls back to geaerospace.com; an explicit blank value +// means "disable contact action buttons", so read the raw setting rather than +// getSetting (which swaps '' for the fallback). +export async function getContactEmailDomain() { + const settings = await loadSettings() + const value = settings['contact_email_domain'] + return (value === undefined || value === null) ? 'geaerospace.com' : value +} + +// Apply per-site favicon + brand color at bootstrap. Empty settings keep the +// shipped defaults. Do not touch the style.css palette here. +export async function applyBranding() { + const favicon = await getFavicon() + if (favicon) { + let link = document.querySelector('link[rel="icon"]') + if (!link) { + link = document.createElement('link') + link.rel = 'icon' + document.head.appendChild(link) + } + link.href = favicon + } + const rootStyle = document.documentElement.style + const primaryColor = await getBrandPrimaryColor() + if (primaryColor) { + rootStyle.setProperty('--primary', primaryColor) + } + // Hover color: use the explicit value, else darken primary ~15%. + const primaryDarkColor = await getBrandPrimaryDarkColor() + if (primaryDarkColor) { + rootStyle.setProperty('--primary-dark', primaryDarkColor) + } else if (primaryColor) { + const derived = darkenHexColor(primaryColor, 0.15) + if (derived) rootStyle.setProperty('--primary-dark', derived) + } + const accentColor = await getBrandAccentColor() + if (accentColor) { + rootStyle.setProperty('--secondary', accentColor) + } + const sidebarColor = await getBrandSidebarColor() + if (sidebarColor) { + rootStyle.setProperty('--sidebar-bg', sidebarColor) + } +} diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue new file mode 100644 index 00000000..44e61ee1 --- /dev/null +++ b/frontend/src/views/AppLayout.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue new file mode 100644 index 00000000..cb2fc628 --- /dev/null +++ b/frontend/src/views/CalendarView.vue @@ -0,0 +1,380 @@ + + + + + diff --git a/frontend/src/views/ChangePassword.vue b/frontend/src/views/ChangePassword.vue new file mode 100644 index 00000000..53273c5b --- /dev/null +++ b/frontend/src/views/ChangePassword.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 00000000..76bdfb60 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,129 @@ + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 00000000..c1f2f6cb --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/frontend/src/views/MapEditor.vue b/frontend/src/views/MapEditor.vue new file mode 100644 index 00000000..39330ffb --- /dev/null +++ b/frontend/src/views/MapEditor.vue @@ -0,0 +1,412 @@ + + + + + diff --git a/frontend/src/views/MapView.vue b/frontend/src/views/MapView.vue new file mode 100644 index 00000000..88e2e5f2 --- /dev/null +++ b/frontend/src/views/MapView.vue @@ -0,0 +1,390 @@ + + + + + diff --git a/frontend/src/views/SearchResults.vue b/frontend/src/views/SearchResults.vue new file mode 100644 index 00000000..3e7c5240 --- /dev/null +++ b/frontend/src/views/SearchResults.vue @@ -0,0 +1,479 @@ + + + + + diff --git a/frontend/src/views/SetupWizard.vue b/frontend/src/views/SetupWizard.vue new file mode 100644 index 00000000..8b142063 --- /dev/null +++ b/frontend/src/views/SetupWizard.vue @@ -0,0 +1,458 @@ + + + + + diff --git a/frontend/src/views/ShopfloorDashboard.vue b/frontend/src/views/ShopfloorDashboard.vue new file mode 100644 index 00000000..149ead8a --- /dev/null +++ b/frontend/src/views/ShopfloorDashboard.vue @@ -0,0 +1,762 @@ + + + + + diff --git a/frontend/src/views/TVDashboard.vue b/frontend/src/views/TVDashboard.vue new file mode 100644 index 00000000..16c4ff4e --- /dev/null +++ b/frontend/src/views/TVDashboard.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/frontend/src/views/applications/ApplicationDetail.vue b/frontend/src/views/applications/ApplicationDetail.vue new file mode 100644 index 00000000..1f93ab34 --- /dev/null +++ b/frontend/src/views/applications/ApplicationDetail.vue @@ -0,0 +1,366 @@ + + + + + diff --git a/frontend/src/views/applications/ApplicationForm.vue b/frontend/src/views/applications/ApplicationForm.vue new file mode 100644 index 00000000..38f63315 --- /dev/null +++ b/frontend/src/views/applications/ApplicationForm.vue @@ -0,0 +1,279 @@ + + + + + diff --git a/frontend/src/views/applications/ApplicationsList.vue b/frontend/src/views/applications/ApplicationsList.vue new file mode 100644 index 00000000..9ce783ad --- /dev/null +++ b/frontend/src/views/applications/ApplicationsList.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/frontend/src/views/employees/EmployeeDetail.vue b/frontend/src/views/employees/EmployeeDetail.vue new file mode 100644 index 00000000..76f55da7 --- /dev/null +++ b/frontend/src/views/employees/EmployeeDetail.vue @@ -0,0 +1,348 @@ + + + + + diff --git a/frontend/src/views/geenforce/EnforcementReports.vue b/frontend/src/views/geenforce/EnforcementReports.vue new file mode 100644 index 00000000..a0aa0935 --- /dev/null +++ b/frontend/src/views/geenforce/EnforcementReports.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/frontend/src/views/geenforce/GeEnforceLayout.vue b/frontend/src/views/geenforce/GeEnforceLayout.vue new file mode 100644 index 00000000..b5c74af8 --- /dev/null +++ b/frontend/src/views/geenforce/GeEnforceLayout.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/frontend/src/views/geenforce/ManifestEditor.vue b/frontend/src/views/geenforce/ManifestEditor.vue new file mode 100644 index 00000000..05dd0dd4 --- /dev/null +++ b/frontend/src/views/geenforce/ManifestEditor.vue @@ -0,0 +1,1051 @@ + + + + + diff --git a/frontend/src/views/geenforce/entryForm.js b/frontend/src/views/geenforce/entryForm.js new file mode 100644 index 00000000..7384f639 --- /dev/null +++ b/frontend/src/views/geenforce/entryForm.js @@ -0,0 +1,183 @@ +// Pure, framework-free helpers for the GE-Enforce manifest editor. +// +// ManifestEditor.vue imports these directly (the component no longer keeps its +// own copies), so the unit tests in entryForm.spec.js exercise the shipped +// code path. Change the editor logic HERE. +// +// Everything here is a plain function of its inputs. No Vue, no reactivity, +// no network. That is the whole point - deterministic logic we can pin down. + +export const ENTRY_TYPES = ['MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry'] +export const REG_TYPES = ['String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary'] +export const DETECTION_METHODS = ['Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile', + 'ValueMatches', 'pnputil', 'Always'] +export const INUSE_BEHAVIORS = ['Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot'] + +// Plain-language description of each detection method, shown under the Detection +// method dropdown so a first-time site admin understands what "present" means +// for the method they picked. Key '' is the no-detection case. +export const DETECTION_METHOD_HINTS = { + '': 'No detection rule: the action runs every cycle.', + Registry: 'Already correct if the registry value at Detection path/name exists (and equals Detection value when one is set).', + File: 'Already correct if the file at Detection path exists.', + FileVersion: 'Already correct if the file at Detection path is at Detection value or newer. This target feeds the Compliance panel.', + Hash: 'Already correct if the file at Detection path matches the SHA256 in Detection value. Re-copies when the file changed.', + MarkerFile: 'Already correct if the marker file at Detection path exists. Installs once, then the marker suppresses reruns.', + ValueMatches: 'Already correct if the registry value at Detection path/name equals Detection value exactly.', + pnputil: 'Already correct if a driver matching Detection pattern is staged in the Windows driver store. For INF entries.', + Always: 'Never counts as present, so the action runs every cycle. Same effect as no detection rule.', +} + +// Description for the currently selected detection method, or '' if unknown. +export function detectionMethodHint(method) { + return DETECTION_METHOD_HINTS[method || ''] || '' +} + +// One blank entry form, matching the shape ManifestEditor seeds for a new entry. +export function blankEntry() { + return { Type: 'MSI', DetectionMethod: '', RegType: 'String', + inuseBehavior: '', inuseProcesses: [], appid: null, + PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false } +} + +// Split a comma list into trimmed non-empty parts. +export function splitList(value) { + return (value || '').split(',').map(item => item.trim()).filter(Boolean) +} + +// Turn an edit-form object into the API body sent to create/update an entry. +// +// Rules that matter (and that the specs pin): +// - appid is always present (null unlinks). It is shopdb metadata; the backend +// keeps it OFF the manifest JSON, so it is NOT one of the manifest scalars. +// - empty/undefined/null scalars are dropped. +// - Registry DWord/QWord values become real numbers; other reg types stay strings. +// - comma lists become arrays, and are dropped when empty. +// - preinstall flags only appear when truthy. +// - InUseCheck carries per-process Name, optional ExePath, optional numeric timeout. +export function buildEntryPayload(form) { + // appid is shopdb metadata, always sent (null unlinks); the backend keeps it + // off the manifest JSON. + const out = { Name: form.Name, Type: form.Type, appid: form.appid ?? null } + const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source', + 'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath', + 'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion', + 'LogFile', 'ApplyMode', 'UpdateWindow', '_comment'] + for (const key of scalars) { + if (form[key] !== undefined && form[key] !== '' && form[key] !== null) out[key] = form[key] + } + if (form.DetectionMethod) out.DetectionMethod = form.DetectionMethod + if (form.WaitTimeoutSec) out.WaitTimeoutSec = form.WaitTimeoutSec + if (form.Type === 'Registry' && form.RegValue !== undefined && form.RegValue !== '') { + out.RegValue = ['DWord', 'QWord'].includes(form.RegType) ? Number(form.RegValue) : form.RegValue + } + const pctypes = splitList(form.PCTypes) + if (pctypes.length) out.PCTypes = pctypes + const hostnames = splitList(form.TargetHostnames) + if (hostnames.length) out.TargetHostnames = hostnames + const machinenumbers = splitList(form.TargetMachineNumbers) + if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers + for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) { + if (form[flag]) out[flag] = true + } + if (form.inuseBehavior) { + out.InUseCheck = { + Behavior: form.inuseBehavior, + Processes: (form.inuseProcesses || []).filter(process => process.name).map(process => { + const processData = { Name: process.name } + if (process.exepath) processData.ExePath = process.exepath + if (process.timeout !== null && process.timeout !== '' && process.timeout !== undefined) { + processData.GracefulCloseTimeoutSec = Number(process.timeout) + } + return processData + }), + } + } + return out +} + +// Which entry types the type dropdown offers, given the scope phase. Preinstall +// supports MSI/EXE only, but keeps a current out-of-set value visible. +export function availableEntryTypes(isPreinstall, currentType) { + if (!isPreinstall) return ENTRY_TYPES + const allowed = ['MSI', 'EXE'] + return currentType && !allowed.includes(currentType) ? [...allowed, currentType] : allowed +} + +// Which detection methods the dropdown offers. Preinstall supports Registry/File +// only, but keeps a current out-of-set value visible. +export function availableDetectionMethods(isPreinstall, currentMethod) { + if (!isPreinstall) return DETECTION_METHODS + const allowed = ['Registry', 'File'] + return currentMethod && !allowed.includes(currentMethod) ? [...allowed, currentMethod] : allowed +} + +// Which targeting gates a scope actually surfaces by default (before "Show all"). +export function targetingGates(scope) { + if (!scope) { + return { pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false } + } + const name = (scope.scopename || '').toLowerCase() + const entries = scope.entries || [] + const fleetwide = scope.iscommon || scope.phase === 'preinstall' || name === 'common' + return { + pctypes: fleetwide || entries.some(e => (e.PCTypes || []).length), + cmmversion: /cmm/.test(name) || entries.some(e => e._CmmVersion), + // Data-driven (a loose name match would wrongly flag 'nocollections'). + machinenumbers: entries.some(e => (e.TargetMachineNumbers || []).length), + hostnames: entries.some(e => (e.TargetHostnames || []).length), + } +} + +// The contextual hint under the Targeting header. +export function targetingHint(scope) { + if (!scope) return '' + if (scope.iscommon || scope.scopename === 'common') { + return 'Fleet-wide manifest: use PC types to target which types get this entry.' + } + if (scope.phase === 'preinstall') { + return 'Preinstall manifest: use PC types to target; preinstall flags apply here.' + } + return `This manifest already runs only on ${scope.scopename} PCs. ` + + 'Targeting below narrows within that (version, bay, or subtype).' +} + +// One-line summary of what a scope installs (installer entries only). +export function scopeSummary(scope) { + if (!scope || !scope.entries || !scope.entries.length) return '' + const installers = scope.entries + .filter(e => ['MSI', 'EXE', 'CMD', 'BAT'].includes(e.Type)) + .map(e => e.Name) + const shown = installers.slice(0, 6).join(', ') + const more = installers.length > 6 ? `, +${installers.length - 6} more` : '' + const apps = installers.length ? `Installs ${shown}${more}. ` : '' + return `${apps}${scope.entries.length} entries; runs after common.` +} + +// Verb table for describeEntry. +export const ACTION_VERB = { + MSI: 'Installs', EXE: 'Installs', CMD: 'Runs', BAT: 'Runs', + PS1: 'Runs a script for', INF: 'Installs a driver for', + File: 'Copies a file for', Registry: 'Sets a registry value for', +} + +// Plain-English one-liner: what an entry does, when it self-heals, who it hits. +export function describeEntry(entry) { + let text = `${ACTION_VERB[entry.Type] || 'Applies'} ${entry.Name}` + const method = entry.DetectionMethod + if (!method || method === 'Always') text += '; runs every cycle' + else if (method === 'FileVersion') text += `; reinstalls unless version is ${entry.DetectionValue || 'set'}` + else if (method === 'Hash') text += '; re-copies if the file changed' + else if (method === 'MarkerFile') text += '; installs once' + else text += '; reinstalls if not detected' + if (entry._CmmVersion) text += `; CMM ${entry._CmmVersion} bays only` + else if (entry.TargetMachineNumbers && entry.TargetMachineNumbers.length) { + text += `; ${entry.TargetMachineNumbers.length} specific bay(s)` + } else if (entry.TargetHostnames && entry.TargetHostnames.length) { + text += '; specific hostname(s)' + } else if (entry.PCTypes && entry.PCTypes.length) { + text += `; ${entry.PCTypes.length} PC type(s)` + } + if (entry.appname) text += `; tracked: ${entry.appname}` + return text +} diff --git a/frontend/src/views/geenforce/entryForm.spec.js b/frontend/src/views/geenforce/entryForm.spec.js new file mode 100644 index 00000000..bd299230 --- /dev/null +++ b/frontend/src/views/geenforce/entryForm.spec.js @@ -0,0 +1,321 @@ +import { describe, it, expect } from 'vitest' +import { + blankEntry, + splitList, + buildEntryPayload, + availableEntryTypes, + availableDetectionMethods, + targetingGates, + targetingHint, + scopeSummary, + describeEntry, + detectionMethodHint, + ENTRY_TYPES, + DETECTION_METHODS, +} from './entryForm.js' + +// These specs pin the deterministic logic that turns the manifest editor form +// into an API body, and the various display helpers. This is the code most +// likely to silently regress (numeric coercion, dropped-vs-kept fields, the +// appid-must-never-be-a-manifest-scalar rule). + +describe('splitList', () => { + it('trims, drops empties, and returns an array', () => { + expect(splitList('a, b ,, c ')).toEqual(['a', 'b', 'c']) + }) + it('returns an empty array for null/empty', () => { + expect(splitList('')).toEqual([]) + expect(splitList(null)).toEqual([]) + expect(splitList(undefined)).toEqual([]) + }) +}) + +describe('buildEntryPayload - appid handling', () => { + it('always emits appid as a top-level key, defaulting to null', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'MSI' }) + expect(out.appid).toBeNull() + expect('appid' in out).toBe(true) + }) + it('passes a linked appid through unchanged', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'MSI', appid: 41 }) + expect(out.appid).toBe(41) + }) + it('never lets appid become a manifest scalar (it is metadata, not a manifest key)', () => { + // appid lives at the top level as metadata; it must not appear nested in + // any manifest sub-structure. Guard against a future refactor smuggling it + // into e.g. InUseCheck or a detection block. + const out = buildEntryPayload({ + Name: 'x', Type: 'Registry', appid: 7, RegType: 'DWord', RegValue: '1', + DetectionMethod: 'Registry', inuseBehavior: 'ForceClose', + inuseProcesses: [{ name: 'p', exepath: '', timeout: null }], + }) + // Only the top-level appid key carries it. + const serialized = JSON.stringify({ ...out, appid: undefined }) + expect(serialized.includes('appid')).toBe(false) + expect(serialized.toLowerCase().includes('"appid"')).toBe(false) + }) +}) + +describe('buildEntryPayload - scalar filtering', () => { + it('drops empty-string, null, and undefined scalars', () => { + const out = buildEntryPayload({ + Name: 'x', Type: 'MSI', Installer: '', InstallArgs: null, + Script: undefined, LogFile: 'setup.log', + }) + expect('Installer' in out).toBe(false) + expect('InstallArgs' in out).toBe(false) + expect('Script' in out).toBe(false) + expect(out.LogFile).toBe('setup.log') + }) + it('keeps the underscore-prefixed scalars (_CmmVersion, _comment)', () => { + const out = buildEntryPayload({ + Name: 'x', Type: 'MSI', _CmmVersion: '2019', _comment: 'note', + }) + expect(out._CmmVersion).toBe('2019') + expect(out._comment).toBe('note') + }) + it('only emits DetectionMethod when set', () => { + expect('DetectionMethod' in buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: '' })).toBe(false) + expect(buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: 'FileVersion' }).DetectionMethod).toBe('FileVersion') + }) + it('drops a zero/falsy WaitTimeoutSec but keeps a real one', () => { + expect('WaitTimeoutSec' in buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 0 })).toBe(false) + expect(buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 300 }).WaitTimeoutSec).toBe(300) + }) +}) + +describe('buildEntryPayload - RegValue coercion', () => { + it('coerces DWord to a Number', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '1' }) + expect(out.RegValue).toBe(1) + expect(typeof out.RegValue).toBe('number') + }) + it('coerces QWord to a Number', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'QWord', RegValue: '42' }) + expect(out.RegValue).toBe(42) + }) + it('leaves String reg values as strings', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'String', RegValue: 'hello' }) + expect(out.RegValue).toBe('hello') + expect(typeof out.RegValue).toBe('string') + }) + it('does not emit RegValue for a non-Registry type', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'MSI', RegType: 'DWord', RegValue: '1' }) + expect('RegValue' in out).toBe(false) + }) + it('drops an empty RegValue even for Registry', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '' }) + expect('RegValue' in out).toBe(false) + }) +}) + +describe('buildEntryPayload - comma lists to arrays', () => { + it('splits PCTypes / TargetHostnames / TargetMachineNumbers', () => { + const out = buildEntryPayload({ + Name: 'x', Type: 'MSI', + PCTypes: 'gea-shopfloor-cmm, gea-shopfloor-collections', + TargetHostnames: 'host-a, host-b', + TargetMachineNumbers: '0101, 0102', + }) + expect(out.PCTypes).toEqual(['gea-shopfloor-cmm', 'gea-shopfloor-collections']) + expect(out.TargetHostnames).toEqual(['host-a', 'host-b']) + expect(out.TargetMachineNumbers).toEqual(['0101', '0102']) + }) + it('omits the array keys when the list is empty', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'MSI', PCTypes: '', TargetHostnames: ' ' }) + expect('PCTypes' in out).toBe(false) + expect('TargetHostnames' in out).toBe(false) + }) +}) + +describe('buildEntryPayload - preinstall flags', () => { + it('emits only the truthy flags', () => { + const out = buildEntryPayload({ + Name: 'x', Type: 'MSI', + PreEnrollment: true, KillAfterDetection: false, PCTypesStrict: true, + }) + expect(out.PreEnrollment).toBe(true) + expect(out.PCTypesStrict).toBe(true) + expect('KillAfterDetection' in out).toBe(false) + }) +}) + +describe('buildEntryPayload - InUseCheck processes', () => { + it('carries Name, optional ExePath, and a numeric timeout per process', () => { + const out = buildEntryPayload({ + Name: 'x', Type: 'MSI', inuseBehavior: 'CloseAndReopen', + inuseProcesses: [ + { name: 'pcdmis', exepath: 'C:\\pcd\\pcdmis.exe', timeout: 30 }, + { name: 'nodmis', exepath: '', timeout: null }, + ], + }) + expect(out.InUseCheck.Behavior).toBe('CloseAndReopen') + expect(out.InUseCheck.Processes).toEqual([ + { Name: 'pcdmis', ExePath: 'C:\\pcd\\pcdmis.exe', GracefulCloseTimeoutSec: 30 }, + { Name: 'nodmis' }, + ]) + }) + it('drops processes with no name', () => { + const out = buildEntryPayload({ + Name: 'x', Type: 'MSI', inuseBehavior: 'ForceClose', + inuseProcesses: [{ name: '', exepath: 'x', timeout: 5 }, { name: 'keep', timeout: 5 }], + }) + expect(out.InUseCheck.Processes).toEqual([{ Name: 'keep', GracefulCloseTimeoutSec: 5 }]) + }) + it('coerces a string timeout to a number', () => { + const out = buildEntryPayload({ + Name: 'x', Type: 'MSI', inuseBehavior: 'Defer', + inuseProcesses: [{ name: 'p', exepath: '', timeout: '15' }], + }) + expect(out.InUseCheck.Processes[0].GracefulCloseTimeoutSec).toBe(15) + }) + it('emits no InUseCheck when there is no behavior', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'MSI', inuseBehavior: '' }) + expect('InUseCheck' in out).toBe(false) + }) +}) + +describe('buildEntryPayload - a blank new entry round-trips', () => { + it('produces a minimal body from blankEntry (name/type + null appid + default RegType)', () => { + const out = buildEntryPayload({ ...blankEntry(), Name: 'Fresh' }) + // Type MSI, appid null. RegType is default String but non-Registry, so it + // is still carried as a scalar (it is in the scalar list). Confirm the shape. + expect(out.Name).toBe('Fresh') + expect(out.Type).toBe('MSI') + expect(out.appid).toBeNull() + expect('InUseCheck' in out).toBe(false) + expect('PCTypes' in out).toBe(false) + }) +}) + +describe('availableEntryTypes', () => { + it('returns the full list for a non-preinstall scope', () => { + expect(availableEntryTypes(false, 'File')).toEqual(ENTRY_TYPES) + }) + it('restricts to MSI/EXE for preinstall', () => { + expect(availableEntryTypes(true, 'MSI')).toEqual(['MSI', 'EXE']) + }) + it('keeps an out-of-set current value visible in preinstall', () => { + expect(availableEntryTypes(true, 'Registry')).toEqual(['MSI', 'EXE', 'Registry']) + }) +}) + +describe('availableDetectionMethods', () => { + it('returns the full list for a non-preinstall scope', () => { + expect(availableDetectionMethods(false, 'Hash')).toEqual(DETECTION_METHODS) + }) + it('restricts to Registry/File for preinstall', () => { + expect(availableDetectionMethods(true, 'File')).toEqual(['Registry', 'File']) + }) + it('keeps an out-of-set current value visible in preinstall', () => { + expect(availableDetectionMethods(true, 'FileVersion')).toEqual(['Registry', 'File', 'FileVersion']) + }) +}) + +describe('targetingGates', () => { + it('defaults to pctypes-only when there is no scope', () => { + expect(targetingGates(null)).toEqual({ + pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false, + }) + }) + it('shows pctypes for a fleetwide/common/preinstall scope', () => { + expect(targetingGates({ scopename: 'common', entries: [] }).pctypes).toBe(true) + expect(targetingGates({ scopename: 'x', phase: 'preinstall', entries: [] }).pctypes).toBe(true) + expect(targetingGates({ scopename: 'x', iscommon: true, entries: [] }).pctypes).toBe(true) + }) + it('shows the cmm gate for a cmm-named scope', () => { + expect(targetingGates({ scopename: 'gea-shopfloor-cmm', entries: [] }).cmmversion).toBe(true) + }) + it('does not name-match machinenumbers (data-driven only)', () => { + // 'nocollections' must not trip a loose name match; machinenumbers is purely + // data-driven off the entries. + const gates = targetingGates({ scopename: 'nocollections', entries: [] }) + expect(gates.machinenumbers).toBe(false) + }) + it('surfaces gates from entry data', () => { + const gates = targetingGates({ + scopename: 'per-type', entries: [ + { PCTypes: ['a'] }, { TargetMachineNumbers: ['0101'] }, { TargetHostnames: ['h'] }, + ], + }) + expect(gates.pctypes).toBe(true) + expect(gates.machinenumbers).toBe(true) + expect(gates.hostnames).toBe(true) + }) +}) + +describe('targetingHint', () => { + it('is empty with no scope', () => { + expect(targetingHint(null)).toBe('') + }) + it('describes a fleet-wide common manifest', () => { + expect(targetingHint({ scopename: 'common' })).toMatch(/Fleet-wide/) + }) + it('describes a preinstall manifest', () => { + expect(targetingHint({ scopename: 'x', phase: 'preinstall' })).toMatch(/Preinstall/) + }) + it('names a per-type scope', () => { + expect(targetingHint({ scopename: 'gea-shopfloor-cmm' })).toMatch(/gea-shopfloor-cmm/) + }) +}) + +describe('scopeSummary', () => { + it('is empty with no entries', () => { + expect(scopeSummary({ entries: [] })).toBe('') + expect(scopeSummary(null)).toBe('') + }) + it('lists installer names and the entry count', () => { + const summary = scopeSummary({ + entries: [ + { Type: 'MSI', Name: 'eDNC' }, + { Type: 'Registry', Name: 'reg' }, + ], + }) + expect(summary).toBe('Installs eDNC. 2 entries; runs after common.') + }) + it('truncates past six installers with a +N more', () => { + const entries = Array.from({ length: 8 }, (_, i) => ({ Type: 'MSI', Name: `app${i}` })) + const summary = scopeSummary({ entries }) + expect(summary).toMatch(/\+2 more/) + }) +}) + +describe('describeEntry', () => { + it('describes an MSI with FileVersion detection using the expected version', () => { + const text = describeEntry({ Type: 'MSI', Name: 'PC-DMIS', DetectionMethod: 'FileVersion', DetectionValue: '2019 R1' }) + expect(text).toBe('Installs PC-DMIS; reinstalls unless version is 2019 R1') + }) + it('says runs every cycle for Always / no detection', () => { + expect(describeEntry({ Type: 'CMD', Name: 'x' })).toBe('Runs x; runs every cycle') + expect(describeEntry({ Type: 'CMD', Name: 'x', DetectionMethod: 'Always' })).toBe('Runs x; runs every cycle') + }) + it('appends a CMM gate note', () => { + const text = describeEntry({ Type: 'MSI', Name: 'x', _CmmVersion: '2016' }) + expect(text).toMatch(/CMM 2016 bays only/) + }) + it('appends the tracked app name when present', () => { + const text = describeEntry({ Type: 'MSI', Name: 'x', appname: 'PC-DMIS' }) + expect(text).toMatch(/tracked: PC-DMIS/) + }) + it('falls back to Applies for an unknown type', () => { + expect(describeEntry({ Type: 'Weird', Name: 'x' })).toMatch(/^Applies x/) + }) +}) + +describe('detectionMethodHint', () => { + it('has a non-empty hint for every detection method', () => { + for (const method of DETECTION_METHODS) { + expect(detectionMethodHint(method).length).toBeGreaterThan(0) + } + }) + it('describes the no-detection case for empty/undefined', () => { + expect(detectionMethodHint('')).toMatch(/every cycle/) + expect(detectionMethodHint(undefined)).toMatch(/every cycle/) + }) + it('ties FileVersion to the compliance panel', () => { + expect(detectionMethodHint('FileVersion')).toMatch(/Compliance/) + }) + it('returns empty string for an unknown method', () => { + expect(detectionMethodHint('Nonsense')).toBe('') + }) +}) diff --git a/frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue b/frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue new file mode 100644 index 00000000..d3f7f0e6 --- /dev/null +++ b/frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/frontend/src/views/knowledgebase/KnowledgeBaseForm.vue b/frontend/src/views/knowledgebase/KnowledgeBaseForm.vue new file mode 100644 index 00000000..2726e4a2 --- /dev/null +++ b/frontend/src/views/knowledgebase/KnowledgeBaseForm.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/frontend/src/views/knowledgebase/KnowledgeBaseList.vue b/frontend/src/views/knowledgebase/KnowledgeBaseList.vue new file mode 100644 index 00000000..fd20ebde --- /dev/null +++ b/frontend/src/views/knowledgebase/KnowledgeBaseList.vue @@ -0,0 +1,263 @@ + + + + + diff --git a/frontend/src/views/machines/MachineDetail.vue b/frontend/src/views/machines/MachineDetail.vue new file mode 100644 index 00000000..566197a2 --- /dev/null +++ b/frontend/src/views/machines/MachineDetail.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/frontend/src/views/machines/MachineForm.vue b/frontend/src/views/machines/MachineForm.vue new file mode 100644 index 00000000..7b9c209e --- /dev/null +++ b/frontend/src/views/machines/MachineForm.vue @@ -0,0 +1,706 @@ + + + + + diff --git a/frontend/src/views/machines/MachinesList.vue b/frontend/src/views/machines/MachinesList.vue new file mode 100644 index 00000000..b31db8fe --- /dev/null +++ b/frontend/src/views/machines/MachinesList.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/frontend/src/views/measuringtools/MeasuringToolDetail.vue b/frontend/src/views/measuringtools/MeasuringToolDetail.vue new file mode 100644 index 00000000..a227b954 --- /dev/null +++ b/frontend/src/views/measuringtools/MeasuringToolDetail.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/frontend/src/views/measuringtools/MeasuringToolForm.vue b/frontend/src/views/measuringtools/MeasuringToolForm.vue new file mode 100644 index 00000000..5af746b3 --- /dev/null +++ b/frontend/src/views/measuringtools/MeasuringToolForm.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/frontend/src/views/measuringtools/MeasuringToolsList.vue b/frontend/src/views/measuringtools/MeasuringToolsList.vue new file mode 100644 index 00000000..32cf342a --- /dev/null +++ b/frontend/src/views/measuringtools/MeasuringToolsList.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/frontend/src/views/network/NetworkDeviceDetail.vue b/frontend/src/views/network/NetworkDeviceDetail.vue new file mode 100644 index 00000000..9a1ec717 --- /dev/null +++ b/frontend/src/views/network/NetworkDeviceDetail.vue @@ -0,0 +1,380 @@ + + + + + diff --git a/frontend/src/views/network/NetworkDeviceForm.vue b/frontend/src/views/network/NetworkDeviceForm.vue new file mode 100644 index 00000000..b3a0a8c3 --- /dev/null +++ b/frontend/src/views/network/NetworkDeviceForm.vue @@ -0,0 +1,552 @@ + + + + + diff --git a/frontend/src/views/network/NetworkDevicesList.vue b/frontend/src/views/network/NetworkDevicesList.vue new file mode 100644 index 00000000..e528ecfc --- /dev/null +++ b/frontend/src/views/network/NetworkDevicesList.vue @@ -0,0 +1,347 @@ + + + + + diff --git a/frontend/src/views/network/NetworkHub.vue b/frontend/src/views/network/NetworkHub.vue new file mode 100644 index 00000000..2a9015f9 --- /dev/null +++ b/frontend/src/views/network/NetworkHub.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/frontend/src/views/network/SubnetDetail.vue b/frontend/src/views/network/SubnetDetail.vue new file mode 100644 index 00000000..6975044c --- /dev/null +++ b/frontend/src/views/network/SubnetDetail.vue @@ -0,0 +1,88 @@ + + + diff --git a/frontend/src/views/network/SubnetsBrowse.vue b/frontend/src/views/network/SubnetsBrowse.vue new file mode 100644 index 00000000..ad2fa4ed --- /dev/null +++ b/frontend/src/views/network/SubnetsBrowse.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/views/notifications/NotificationForm.vue b/frontend/src/views/notifications/NotificationForm.vue new file mode 100644 index 00000000..d985791b --- /dev/null +++ b/frontend/src/views/notifications/NotificationForm.vue @@ -0,0 +1,642 @@ + + + + + diff --git a/frontend/src/views/notifications/NotificationTypesList.vue b/frontend/src/views/notifications/NotificationTypesList.vue new file mode 100644 index 00000000..6d477774 --- /dev/null +++ b/frontend/src/views/notifications/NotificationTypesList.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/frontend/src/views/notifications/NotificationsList.vue b/frontend/src/views/notifications/NotificationsList.vue new file mode 100644 index 00000000..7540c476 --- /dev/null +++ b/frontend/src/views/notifications/NotificationsList.vue @@ -0,0 +1,175 @@ + + + diff --git a/frontend/src/views/pcs/PCDetail.vue b/frontend/src/views/pcs/PCDetail.vue new file mode 100644 index 00000000..b7c501a1 --- /dev/null +++ b/frontend/src/views/pcs/PCDetail.vue @@ -0,0 +1,426 @@ + + + + + diff --git a/frontend/src/views/pcs/PCForm.vue b/frontend/src/views/pcs/PCForm.vue new file mode 100644 index 00000000..f42bb7d5 --- /dev/null +++ b/frontend/src/views/pcs/PCForm.vue @@ -0,0 +1,609 @@ + + + + + diff --git a/frontend/src/views/pcs/PCsList.vue b/frontend/src/views/pcs/PCsList.vue new file mode 100644 index 00000000..3c05e742 --- /dev/null +++ b/frontend/src/views/pcs/PCsList.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/frontend/src/views/print/AssetLabel.vue b/frontend/src/views/print/AssetLabel.vue new file mode 100644 index 00000000..53760d4e --- /dev/null +++ b/frontend/src/views/print/AssetLabel.vue @@ -0,0 +1,425 @@ + + + + + diff --git a/frontend/src/views/print/AssetLabelBatch.vue b/frontend/src/views/print/AssetLabelBatch.vue new file mode 100644 index 00000000..c02bb16a --- /dev/null +++ b/frontend/src/views/print/AssetLabelBatch.vue @@ -0,0 +1,501 @@ + + + + + diff --git a/frontend/src/views/print/MachineBadge.vue b/frontend/src/views/print/MachineBadge.vue new file mode 100644 index 00000000..ddf0f860 --- /dev/null +++ b/frontend/src/views/print/MachineBadge.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/frontend/src/views/print/PrinterQRBatch.vue b/frontend/src/views/print/PrinterQRBatch.vue new file mode 100644 index 00000000..5877b250 --- /dev/null +++ b/frontend/src/views/print/PrinterQRBatch.vue @@ -0,0 +1,307 @@ + + + + + diff --git a/frontend/src/views/print/PrinterQRSingle.vue b/frontend/src/views/print/PrinterQRSingle.vue new file mode 100644 index 00000000..b87a6603 --- /dev/null +++ b/frontend/src/views/print/PrinterQRSingle.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/frontend/src/views/print/USBLabelBatch.vue b/frontend/src/views/print/USBLabelBatch.vue new file mode 100644 index 00000000..ef8ece44 --- /dev/null +++ b/frontend/src/views/print/USBLabelBatch.vue @@ -0,0 +1,425 @@ + + + + + diff --git a/frontend/src/views/print/assetLabel.js b/frontend/src/views/print/assetLabel.js new file mode 100644 index 00000000..75b861cf --- /dev/null +++ b/frontend/src/views/print/assetLabel.js @@ -0,0 +1,99 @@ +// Shared wiring for the asset label pages (single AssetLabel.vue and batch +// AssetLabelBatch.vue): per-type api/route config plus the "what does the code +// encode" resolution so both surfaces behave identically. +import { + machinesApi, computersApi, printersApi, networkApi, measuringtoolsApi, +} from '../../api' +import { fillUrlTemplate } from '@/utils/qrTarget' +import { getSetting, getSiteBaseUrl } from '@/utils/siteSettings' + +// Per asset-type: which api loads it, its detail/list routes, the extension key +// nested in the merged payload, and its qr_target_ settings key. +export const TYPE_CONFIG = { + machine: { + api: machinesApi, extkey: 'machine', targetKey: 'qr_target_machine', + label: 'Machine', detailPath: id => `/machines/${id}`, listPath: '/machines', + }, + computer: { + api: computersApi, extkey: 'computer', targetKey: 'qr_target_computer', + label: 'Computer', detailPath: id => `/pcs/${id}`, listPath: '/pcs', + }, + printer: { + api: printersApi, extkey: 'printer', targetKey: 'qr_target_printer', + label: 'Printer', detailPath: id => `/printers/${id}`, listPath: '/printers', + }, + network_device: { + api: networkApi, extkey: 'network_device', targetKey: 'qr_target_network_device', + label: 'Network Device', detailPath: id => `/network/${id}`, listPath: '/network', + }, + measuring_tool: { + api: measuringtoolsApi, extkey: 'measuringtool', targetKey: 'qr_target_measuring_tool', + label: 'Measuring Tool', detailPath: id => `/measuringtools/${id}`, listPath: '/measuringtools', + }, +} + +// Hardcoded fallback default per type when the site setting is unset. Machines +// encode their machine number, measuring tools their inspection location. +const DEFAULT_ENCODES = { machine: 'assetnumber', measuring_tool: 'location' } + +export function hasLocationType(assettype) { + return assettype === 'measuring_tool' +} + +// The default encode mode for a type: the label_default_encodes_ site +// setting when valid, else the hardcoded fallback. +export async function resolveDefaultEncodes(assettype) { + const valid = ['assetpage', 'assetnumber', 'serialnumber', 'custom'] + if (hasLocationType(assettype)) valid.push('location') + const seeded = await getSetting(`label_default_encodes_${assettype}`, '') + return valid.includes(seeded) ? seeded : (DEFAULT_ENCODES[assettype] || 'assetpage') +} + +// 'location' degrades to the asset page when the asset has no location code. +export function effectiveEncodes(encodes, asset) { + if (encodes === 'location' && !asset?.locationcode) return 'assetpage' + return encodes +} + +function tokensFor(asset) { + return { + assetid: asset.assetid || '', + assetnumber: asset.assetnumber || '', + serialnumber: asset.serialnumber || '', + name: asset.name || '', + pluginid: asset.pluginid || '', + locationcode: asset.locationcode || '', + locationname: asset.locationname || '', + } +} + +// The string a label's code encodes for one asset. Empty when the chosen field +// has no value (e.g. serial number on an asset with none). +export async function resolveCodeText(assettype, asset, encodes) { + const config = TYPE_CONFIG[assettype] + if (!config || !asset) return '' + const detailId = asset.pluginid + const assetPageUrl = `${await getSiteBaseUrl()}${config.detailPath(detailId)}` + switch (effectiveEncodes(encodes, asset)) { + case 'assetnumber': return asset.assetnumber || '' + case 'serialnumber': return asset.serialnumber || '' + case 'location': return asset.locationcode || '' + case 'custom': { + const template = (await getSetting(config.targetKey, '')).trim() + return template ? fillUrlTemplate(template, tokensFor(asset)) : assetPageUrl + } + case 'assetpage': + default: return assetPageUrl + } +} + +// Human caption printed under the code. +export function captionFor(asset, encodes) { + if (!asset) return '' + switch (effectiveEncodes(encodes, asset)) { + case 'location': return asset.locationcode || '' + case 'serialnumber': return asset.serialnumber || '' + case 'assetnumber': + default: return asset.assetnumber || '' + } +} diff --git a/frontend/src/views/print/qrLogo.js b/frontend/src/views/print/qrLogo.js new file mode 100644 index 00000000..8d865fc6 --- /dev/null +++ b/frontend/src/views/print/qrLogo.js @@ -0,0 +1,62 @@ +// Shared GE monogram + QR-with-logo rendering for the printer QR label pages. +// Both PrinterQRBatch and PrinterQRSingle render each QR to a data-URL image +// (canvases print unreliably) with the GE monogram composited in the center. +import QRCode from 'qrcode' +import { getQrLogo } from '@/utils/siteSettings' + +const GE_LOGO_SVG = `` + +let logoImage = null + +// Load the configured QR overlay image. On any load failure, fall back to the +// built-in GE monogram (last-resort constant above). +function loadLogo(url) { + if (logoImage) return Promise.resolve(logoImage) + return new Promise(resolve => { + const img = new Image() + img.onload = () => { logoImage = img; resolve(img) } + img.onerror = () => { + const fallback = new Image() + fallback.onload = () => { logoImage = fallback; resolve(fallback) } + fallback.onerror = () => resolve(null) + fallback.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(GE_LOGO_SVG) + } + img.src = url + }) +} + +function drawLogoOverlay(canvas, logo) { + const canvasContext = canvas.getContext('2d') + const size = canvas.width + const logoSize = Math.round(size * 0.22) + const x = (size - logoSize) / 2 + const y = (size - logoSize) / 2 + + // White circle background behind the monogram + canvasContext.beginPath() + canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2) + canvasContext.fillStyle = '#fff' + canvasContext.fill() + + if (logo) { + canvasContext.drawImage(logo, x, y, logoSize, logoSize) + } +} + +// Render a QR for `url` to a PNG data URL with the configured logo composited +// in the center. Empty qr_logo setting = no overlay. Returns '' on failure. +export async function renderQrDataUrl(url) { + const qrLogoUrl = await getQrLogo() + const canvas = document.createElement('canvas') + try { + await QRCode.toCanvas(canvas, url, { width: 144, margin: 0, errorCorrectionLevel: 'H' }) + if (qrLogoUrl) { + const logo = await loadLogo(qrLogoUrl) + drawLogoOverlay(canvas, logo) + } + return canvas.toDataURL('image/png') + } catch (err) { + console.error('QR error:', err) + return '' + } +} diff --git a/frontend/src/views/printers/PrinterDetail.vue b/frontend/src/views/printers/PrinterDetail.vue new file mode 100644 index 00000000..62f9a9bc --- /dev/null +++ b/frontend/src/views/printers/PrinterDetail.vue @@ -0,0 +1,457 @@ + + + + + diff --git a/frontend/src/views/printers/PrinterForm.vue b/frontend/src/views/printers/PrinterForm.vue new file mode 100644 index 00000000..906ad70d --- /dev/null +++ b/frontend/src/views/printers/PrinterForm.vue @@ -0,0 +1,655 @@ + + + + + diff --git a/frontend/src/views/printers/PrintersList.vue b/frontend/src/views/printers/PrintersList.vue new file mode 100644 index 00000000..f64acf08 --- /dev/null +++ b/frontend/src/views/printers/PrintersList.vue @@ -0,0 +1,167 @@ + + + diff --git a/frontend/src/views/reports/CalibrationReport.vue b/frontend/src/views/reports/CalibrationReport.vue new file mode 100644 index 00000000..79d95b02 --- /dev/null +++ b/frontend/src/views/reports/CalibrationReport.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/frontend/src/views/reports/PCRelationshipsReport.vue b/frontend/src/views/reports/PCRelationshipsReport.vue new file mode 100644 index 00000000..9cc0862f --- /dev/null +++ b/frontend/src/views/reports/PCRelationshipsReport.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/frontend/src/views/reports/ReportsIndex.vue b/frontend/src/views/reports/ReportsIndex.vue new file mode 100644 index 00000000..c2a5ccc3 --- /dev/null +++ b/frontend/src/views/reports/ReportsIndex.vue @@ -0,0 +1,514 @@ + + + + + diff --git a/frontend/src/views/reports/TonerReport.vue b/frontend/src/views/reports/TonerReport.vue new file mode 100644 index 00000000..95174275 --- /dev/null +++ b/frontend/src/views/reports/TonerReport.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/frontend/src/views/reports/WarrantyReport.vue b/frontend/src/views/reports/WarrantyReport.vue new file mode 100644 index 00000000..e4c98f60 --- /dev/null +++ b/frontend/src/views/reports/WarrantyReport.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/frontend/src/views/settings/AccessProtocolsList.vue b/frontend/src/views/settings/AccessProtocolsList.vue new file mode 100644 index 00000000..ec4b71bb --- /dev/null +++ b/frontend/src/views/settings/AccessProtocolsList.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/frontend/src/views/settings/ApiTokensList.vue b/frontend/src/views/settings/ApiTokensList.vue new file mode 100644 index 00000000..ea34cbd3 --- /dev/null +++ b/frontend/src/views/settings/ApiTokensList.vue @@ -0,0 +1,551 @@ + + + + + diff --git a/frontend/src/views/settings/AssetIdentifiersSettings.vue b/frontend/src/views/settings/AssetIdentifiersSettings.vue new file mode 100644 index 00000000..2059a812 --- /dev/null +++ b/frontend/src/views/settings/AssetIdentifiersSettings.vue @@ -0,0 +1,58 @@ + + + diff --git a/frontend/src/views/settings/AssetTypesList.vue b/frontend/src/views/settings/AssetTypesList.vue new file mode 100644 index 00000000..60677999 --- /dev/null +++ b/frontend/src/views/settings/AssetTypesList.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/src/views/settings/AuditLogs.vue b/frontend/src/views/settings/AuditLogs.vue new file mode 100644 index 00000000..043a5488 --- /dev/null +++ b/frontend/src/views/settings/AuditLogs.vue @@ -0,0 +1,389 @@ + + + + + diff --git a/frontend/src/views/settings/AuditSettings.vue b/frontend/src/views/settings/AuditSettings.vue new file mode 100644 index 00000000..7831da07 --- /dev/null +++ b/frontend/src/views/settings/AuditSettings.vue @@ -0,0 +1,51 @@ + + + diff --git a/frontend/src/views/settings/AuthenticationSettings.vue b/frontend/src/views/settings/AuthenticationSettings.vue new file mode 100644 index 00000000..4bc673ab --- /dev/null +++ b/frontend/src/views/settings/AuthenticationSettings.vue @@ -0,0 +1,163 @@ + + + diff --git a/frontend/src/views/settings/BrandingSettings.vue b/frontend/src/views/settings/BrandingSettings.vue new file mode 100644 index 00000000..bf455bc2 --- /dev/null +++ b/frontend/src/views/settings/BrandingSettings.vue @@ -0,0 +1,142 @@ + + + diff --git a/frontend/src/views/settings/BusinessUnitsList.vue b/frontend/src/views/settings/BusinessUnitsList.vue new file mode 100644 index 00000000..f4efed79 --- /dev/null +++ b/frontend/src/views/settings/BusinessUnitsList.vue @@ -0,0 +1,191 @@ + + + diff --git a/frontend/src/views/settings/CustomFieldsList.vue b/frontend/src/views/settings/CustomFieldsList.vue new file mode 100644 index 00000000..8bb62238 --- /dev/null +++ b/frontend/src/views/settings/CustomFieldsList.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/frontend/src/views/settings/DashboardDefaultsList.vue b/frontend/src/views/settings/DashboardDefaultsList.vue new file mode 100644 index 00000000..17a9b64a --- /dev/null +++ b/frontend/src/views/settings/DashboardDefaultsList.vue @@ -0,0 +1,190 @@ + + + diff --git a/frontend/src/views/settings/DellWarrantySettings.vue b/frontend/src/views/settings/DellWarrantySettings.vue new file mode 100644 index 00000000..8c3a0daf --- /dev/null +++ b/frontend/src/views/settings/DellWarrantySettings.vue @@ -0,0 +1,117 @@ + + + diff --git a/frontend/src/views/settings/EmailSettings.vue b/frontend/src/views/settings/EmailSettings.vue new file mode 100644 index 00000000..7eb22fdb --- /dev/null +++ b/frontend/src/views/settings/EmailSettings.vue @@ -0,0 +1,216 @@ + + + diff --git a/frontend/src/views/settings/EmployeeDirectory.vue b/frontend/src/views/settings/EmployeeDirectory.vue new file mode 100644 index 00000000..0d9642bd --- /dev/null +++ b/frontend/src/views/settings/EmployeeDirectory.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/frontend/src/views/settings/FloorMapSettings.vue b/frontend/src/views/settings/FloorMapSettings.vue new file mode 100644 index 00000000..624918f6 --- /dev/null +++ b/frontend/src/views/settings/FloorMapSettings.vue @@ -0,0 +1,100 @@ + + + diff --git a/frontend/src/views/settings/GlobalSearchSettings.vue b/frontend/src/views/settings/GlobalSearchSettings.vue new file mode 100644 index 00000000..659f198c --- /dev/null +++ b/frontend/src/views/settings/GlobalSearchSettings.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/views/settings/LocationTypesList.vue b/frontend/src/views/settings/LocationTypesList.vue new file mode 100644 index 00000000..c71115bf --- /dev/null +++ b/frontend/src/views/settings/LocationTypesList.vue @@ -0,0 +1,145 @@ + + + diff --git a/frontend/src/views/settings/LocationsList.vue b/frontend/src/views/settings/LocationsList.vue new file mode 100644 index 00000000..6cb4f433 --- /dev/null +++ b/frontend/src/views/settings/LocationsList.vue @@ -0,0 +1,388 @@ + + + + + diff --git a/frontend/src/views/settings/MachineTypesList.vue b/frontend/src/views/settings/MachineTypesList.vue new file mode 100644 index 00000000..3339aa92 --- /dev/null +++ b/frontend/src/views/settings/MachineTypesList.vue @@ -0,0 +1,145 @@ + + + diff --git a/frontend/src/views/settings/MeasuringToolTypesList.vue b/frontend/src/views/settings/MeasuringToolTypesList.vue new file mode 100644 index 00000000..901e058b --- /dev/null +++ b/frontend/src/views/settings/MeasuringToolTypesList.vue @@ -0,0 +1,157 @@ + + + diff --git a/frontend/src/views/settings/ModelSuppliesList.vue b/frontend/src/views/settings/ModelSuppliesList.vue new file mode 100644 index 00000000..421959e6 --- /dev/null +++ b/frontend/src/views/settings/ModelSuppliesList.vue @@ -0,0 +1,438 @@ + + + + + diff --git a/frontend/src/views/settings/ModelTypesList.vue b/frontend/src/views/settings/ModelTypesList.vue new file mode 100644 index 00000000..80b65d97 --- /dev/null +++ b/frontend/src/views/settings/ModelTypesList.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/frontend/src/views/settings/ModelsList.vue b/frontend/src/views/settings/ModelsList.vue new file mode 100644 index 00000000..73120510 --- /dev/null +++ b/frontend/src/views/settings/ModelsList.vue @@ -0,0 +1,456 @@ + + + + + diff --git a/frontend/src/views/settings/NetworkTypesList.vue b/frontend/src/views/settings/NetworkTypesList.vue new file mode 100644 index 00000000..ef9ce57a --- /dev/null +++ b/frontend/src/views/settings/NetworkTypesList.vue @@ -0,0 +1,145 @@ + + + diff --git a/frontend/src/views/settings/OperatingSystemsList.vue b/frontend/src/views/settings/OperatingSystemsList.vue new file mode 100644 index 00000000..420418f3 --- /dev/null +++ b/frontend/src/views/settings/OperatingSystemsList.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/frontend/src/views/settings/PCTypeMappingSettings.vue b/frontend/src/views/settings/PCTypeMappingSettings.vue new file mode 100644 index 00000000..457a3e7f --- /dev/null +++ b/frontend/src/views/settings/PCTypeMappingSettings.vue @@ -0,0 +1,30 @@ + + + diff --git a/frontend/src/views/settings/PCTypesList.vue b/frontend/src/views/settings/PCTypesList.vue new file mode 100644 index 00000000..0118bfdb --- /dev/null +++ b/frontend/src/views/settings/PCTypesList.vue @@ -0,0 +1,157 @@ + + + diff --git a/frontend/src/views/settings/PluginsList.vue b/frontend/src/views/settings/PluginsList.vue new file mode 100644 index 00000000..d8d4eb80 --- /dev/null +++ b/frontend/src/views/settings/PluginsList.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/frontend/src/views/settings/PrinterDriversList.vue b/frontend/src/views/settings/PrinterDriversList.vue new file mode 100644 index 00000000..efa2d14f --- /dev/null +++ b/frontend/src/views/settings/PrinterDriversList.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/frontend/src/views/settings/PrinterTypesList.vue b/frontend/src/views/settings/PrinterTypesList.vue new file mode 100644 index 00000000..7510cd06 --- /dev/null +++ b/frontend/src/views/settings/PrinterTypesList.vue @@ -0,0 +1,145 @@ + + + diff --git a/frontend/src/views/settings/PrintingSettings.vue b/frontend/src/views/settings/PrintingSettings.vue new file mode 100644 index 00000000..d0c493aa --- /dev/null +++ b/frontend/src/views/settings/PrintingSettings.vue @@ -0,0 +1,192 @@ + + + diff --git a/frontend/src/views/settings/RelationshipTypesList.vue b/frontend/src/views/settings/RelationshipTypesList.vue new file mode 100644 index 00000000..9ed6ef6f --- /dev/null +++ b/frontend/src/views/settings/RelationshipTypesList.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/frontend/src/views/settings/ServiceNowSettings.vue b/frontend/src/views/settings/ServiceNowSettings.vue new file mode 100644 index 00000000..bfdb8c3f --- /dev/null +++ b/frontend/src/views/settings/ServiceNowSettings.vue @@ -0,0 +1,105 @@ + + + diff --git a/frontend/src/views/settings/SettingsIndex.vue b/frontend/src/views/settings/SettingsIndex.vue new file mode 100644 index 00000000..53ab336c --- /dev/null +++ b/frontend/src/views/settings/SettingsIndex.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/frontend/src/views/settings/SettingsLayout.vue b/frontend/src/views/settings/SettingsLayout.vue new file mode 100644 index 00000000..09ff578a --- /dev/null +++ b/frontend/src/views/settings/SettingsLayout.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/frontend/src/views/settings/SiteSettings.vue b/frontend/src/views/settings/SiteSettings.vue new file mode 100644 index 00000000..e52c0e0a --- /dev/null +++ b/frontend/src/views/settings/SiteSettings.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/frontend/src/views/settings/SlideManager.vue b/frontend/src/views/settings/SlideManager.vue new file mode 100644 index 00000000..1aad6a7f --- /dev/null +++ b/frontend/src/views/settings/SlideManager.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/frontend/src/views/settings/StatusesList.vue b/frontend/src/views/settings/StatusesList.vue new file mode 100644 index 00000000..1eb6143a --- /dev/null +++ b/frontend/src/views/settings/StatusesList.vue @@ -0,0 +1,319 @@ + + + + + diff --git a/frontend/src/views/settings/SubnetsList.vue b/frontend/src/views/settings/SubnetsList.vue new file mode 100644 index 00000000..b32e0252 --- /dev/null +++ b/frontend/src/views/settings/SubnetsList.vue @@ -0,0 +1,567 @@ + + + + + diff --git a/frontend/src/views/settings/SupportTeamsList.vue b/frontend/src/views/settings/SupportTeamsList.vue new file mode 100644 index 00000000..ec14e1d1 --- /dev/null +++ b/frontend/src/views/settings/SupportTeamsList.vue @@ -0,0 +1,345 @@ + + + + + diff --git a/frontend/src/views/settings/UsersList.vue b/frontend/src/views/settings/UsersList.vue new file mode 100644 index 00000000..f9dc557c --- /dev/null +++ b/frontend/src/views/settings/UsersList.vue @@ -0,0 +1,746 @@ + + + + + diff --git a/frontend/src/views/settings/VLANsList.vue b/frontend/src/views/settings/VLANsList.vue new file mode 100644 index 00000000..71b2eb85 --- /dev/null +++ b/frontend/src/views/settings/VLANsList.vue @@ -0,0 +1,377 @@ + + + + + diff --git a/frontend/src/views/settings/ZabbixSettings.vue b/frontend/src/views/settings/ZabbixSettings.vue new file mode 100644 index 00000000..4289e445 --- /dev/null +++ b/frontend/src/views/settings/ZabbixSettings.vue @@ -0,0 +1,90 @@ + + + diff --git a/frontend/src/views/settings/settingsNav.js b/frontend/src/views/settings/settingsNav.js new file mode 100644 index 00000000..ea845605 --- /dev/null +++ b/frontend/src/views/settings/settingsNav.js @@ -0,0 +1,127 @@ +// Shared settings navigation catalog. +// Used by SettingsLayout (left rail) and SettingsIndex (landing overview) so the +// grouping lives in one place. +// Order: site identity + the reference-data catalogs users touch daily come +// first, then the platform/system groups (integrations, communication, search, +// plugins, access) cluster together at the end. +import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key } from 'lucide-vue-next' + +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' }, + ], + }, + { + 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/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 + // (right after Machines); the merger fills it in place instead of appending a + // new group at the end. When the plugin is disabled the group stays empty and + // the rail/landing drop it (empty groups never render). + { + title: 'Measuring Tools', + cards: [], + }, + { + 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' }, + ], + }, + { + 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' }, + ], + }, + { + title: 'Displays & Kiosks', + cards: [ + { to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default business unit' }, + { 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' }, + ], + }, + { + title: 'Communication', + cards: [ + { to: '/settings/email', icon: Mail, title: 'Email / SMTP', description: 'SMTP mail host for email notifications, alerts, TLS, from address, and recipients' }, + ], + }, + { + 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' }, + ], + }, + { + title: 'System', + cards: [ + { to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' }, + ], + }, + { + 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' }, + ], + }, +] diff --git a/frontend/src/views/usb/USBDetail.vue b/frontend/src/views/usb/USBDetail.vue new file mode 100644 index 00000000..e32d6c33 --- /dev/null +++ b/frontend/src/views/usb/USBDetail.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/frontend/src/views/usb/USBForm.vue b/frontend/src/views/usb/USBForm.vue new file mode 100644 index 00000000..ab26e478 --- /dev/null +++ b/frontend/src/views/usb/USBForm.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/frontend/src/views/usb/USBList.vue b/frontend/src/views/usb/USBList.vue new file mode 100644 index 00000000..40027c69 --- /dev/null +++ b/frontend/src/views/usb/USBList.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/frontend/src/views/vendors/VendorsList.vue b/frontend/src/views/vendors/VendorsList.vue new file mode 100644 index 00000000..6238a6d8 --- /dev/null +++ b/frontend/src/views/vendors/VendorsList.vue @@ -0,0 +1,330 @@ + + + diff --git a/frontend/src/views/warranty/WarrantiesList.vue b/frontend/src/views/warranty/WarrantiesList.vue new file mode 100644 index 00000000..8e3252d5 --- /dev/null +++ b/frontend/src/views/warranty/WarrantiesList.vue @@ -0,0 +1,407 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 00000000..3e72a853 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'path' + +export default defineConfig({ + // Mount path of the built app. Default '/' (own IIS site / dev). Set + // VITE_BASE_PATH='/ops/' (trailing slash) to build for a subpath mount under + // Default Web Site, e.g. `VITE_BASE_PATH=/ops/ npm run build`. + base: process.env.VITE_BASE_PATH || '/', + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:5001', + changeOrigin: true + }, + '/static': { + target: 'http://localhost:5001', + changeOrigin: true + } + } + } +}) diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js new file mode 100644 index 00000000..0f2577e4 --- /dev/null +++ b/frontend/vitest.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' +import path from 'path' + +// Standalone vitest config so the unit runner stays independent of vite.config.js. +// jsdom gives component specs a DOM; the '@' alias mirrors the app build. +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + environment: 'jsdom', + globals: true, + include: ['src/**/*.spec.js'], + }, +}) diff --git a/migrations/DATA_MIGRATION_GUIDE.md b/migrations/DATA_MIGRATION_GUIDE.md new file mode 100644 index 00000000..6e9c79da --- /dev/null +++ b/migrations/DATA_MIGRATION_GUIDE.md @@ -0,0 +1,13 @@ +# Legacy data migration (superseded) + +The direct-database migration this guide used to describe is retired. Legacy +data now enters through the HTTP import surface, which runs authorization, +validation, auditing, and plugin hooks on every row: + +- Contract: the IMPORT-API wiki page (admin API token + `X-Import-Mode: true`, + which preserves original created/modified timestamps). +- Adoption playbook for a new site: the IMPORT-ADOPTION wiki page. +- Worked reference loader (West Jefferson classic-ASP schema, 15 stages, + idempotent and resumable): `scripts/site_imports/wjf/`. +- Full pilot sequence (stand up + import + verify + cutover): + the PILOT-DEPLOY wiki page. diff --git a/migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md b/migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md new file mode 100644 index 00000000..82612f3e --- /dev/null +++ b/migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md @@ -0,0 +1,89 @@ +# Fix LocationOnly Equipment Types Migration + +## Issue +During the original migration from the VBScript ShopDB site, 110 machines were imported with `LocationOnly` type even though they have models assigned that specify the correct equipment type. + +## Root Cause +The migration copied the `machinetypeid` directly from the `machines` table, but many machines had `machinetypeid=1` (LocationOnly) even when they had a model with a proper type. + +## Affected Records +- **110 machines** in the `machines` table +- **110 equipment** records in the `equipment` table + +## Example +| Machine | Current Type | Should Be | Model | +|---------|-------------|-----------|-------| +| 7502 | LocationOnly | Horizontal Machining Center | a81nx | +| 3108 | LocationOnly | Vertical Lathe | VTM-100 | +| 4001 | LocationOnly | 5-axis Mill | VP9000 | + +## Fix SQL + +### Step 1: Preview affected records +```sql +-- Preview what will be changed +SELECT + m.machineid, + m.machinenumber, + mt_current.machinetype as current_type, + mt_model.machinetype as correct_type, + mo.modelnumber +FROM machines m +JOIN models mo ON mo.modelnumberid = m.modelnumberid +JOIN machinetypes mt_current ON mt_current.machinetypeid = m.machinetypeid +JOIN machinetypes mt_model ON mt_model.machinetypeid = mo.machinetypeid +WHERE m.machinetypeid = 1 -- Currently LocationOnly +AND mo.machinetypeid != 1; -- Model has a real type +``` + +### Step 2: Fix the machines table +```sql +-- Update machines table to use the model's machine type +UPDATE machines m +JOIN models mo ON mo.modelnumberid = m.modelnumberid +SET m.machinetypeid = mo.machinetypeid +WHERE m.machinetypeid = 1 -- Currently LocationOnly +AND mo.machinetypeid != 1; -- Model has a real type +``` + +### Step 3: Fix the equipment table +```sql +-- Update equipment table to match +-- Note: equipmenttypeid values match machinetypeid values +UPDATE equipment e +JOIN assets a ON a.assetid = e.assetid +JOIN machines m ON m.machinenumber = SUBSTRING_INDEX(a.assetnumber, '-', 1) +SET e.equipmenttypeid = m.machinetypeid +WHERE e.equipmenttypeid = 1 -- Currently LocationOnly +AND m.machinetypeid != 1; -- Machine now has correct type +``` + +### Alternative Step 3 (if asset numbers don't match) +```sql +-- Update equipment based on their own model reference +UPDATE equipment e +JOIN models mo ON mo.modelnumberid = e.modelnumberid +SET e.equipmenttypeid = mo.machinetypeid +WHERE e.equipmenttypeid = 1 -- Currently LocationOnly +AND mo.machinetypeid != 1 -- Model has a real type +AND e.modelnumberid IS NOT NULL; +``` + +## Verification +```sql +-- Count remaining LocationOnly records that should have been fixed +SELECT COUNT(*) as remaining_fixable +FROM machines m +JOIN models mo ON mo.modelnumberid = m.modelnumberid +WHERE m.machinetypeid = 1 +AND mo.machinetypeid != 1; +-- Should return 0 after fix +``` + +## Notes for Future Migrations +1. When importing equipment, always check if the model has a type and use that as the default +2. Only use "LocationOnly" for genuine location markers (rooms, offices) without models +3. Validate after import: any equipment with a model should NOT be LocationOnly + +## Date Created +2026-01-27 diff --git a/migrations/MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md b/migrations/MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md new file mode 100644 index 00000000..906af210 --- /dev/null +++ b/migrations/MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md @@ -0,0 +1,92 @@ +# Migrate USB Devices from Equipment Table + +## Issue +6 USB devices were incorrectly stored in the `equipment` table instead of the proper `usbdevices` table. + +## Status +- [x] `pin` field added to `usbdevices` table for encrypted devices +- [x] `pin` field added to USBDevice model +- [x] USB Device equipment type deactivated (equipmenttypeid=44) +- [ ] Migrate 6 USB devices to usbdevices table + +## Affected Records +| Asset Number | Name | Serial Number | +|--------------|------|---------------| +| 82841957-5891 | Green Kingston 64GB | 82841957 | +| 48854302-5892 | Blue Kingston 64GB | 48854302 | +| 75953637-5893 | Blue Kingston 64GB USB 3.0 | 75953637 | +| 41299370-5904 | Lenovo Portable DVD | 41299370 | +| 15492331-5905 | TEAC Portable Floppy Drive | 15492331 | +| 25777358-5906 | Netgear WiFi Adapter | 25777358 | + +## Migration SQL + +### Step 1: Insert into usbdevices +```sql +INSERT INTO usbdevices ( + serialnumber, + label, + assetnumber, + usbdevicetypeid, + storagelocation, + notes, + createddate, + modifieddate, + isactive +) +SELECT + a.serialnumber, + a.name, + a.assetnumber, + NULL, -- Assign proper type later + NULL, -- Storage location TBD + a.notes, + a.createddate, + a.modifieddate, + a.isactive +FROM assets a +JOIN equipment e ON e.assetid = a.assetid +WHERE e.equipmenttypeid = 44; -- USB Device type +``` + +### Step 2: Delete from equipment table +```sql +DELETE e FROM equipment e +JOIN assets a ON a.assetid = e.assetid +WHERE e.equipmenttypeid = 44; +``` + +### Step 3: Delete from assets table +```sql +DELETE a FROM assets a +JOIN equipment e ON e.assetid = a.assetid +WHERE e.equipmenttypeid = 44; + +-- Note: Run step 2 first since it references assets +``` + +### Alternative: Manual Migration +Since there are only 6 devices, you may prefer to: +1. Manually create them in the USB Devices section +2. Delete the equipment/asset records + +## USB Device Types to Create +```sql +-- Check if types exist, create if needed +INSERT IGNORE INTO usbdevicetypes (typename, description, icon) VALUES +('Flash Drive', 'USB flash drive / thumb drive', 'usb'), +('External HDD', 'External hard disk drive', 'harddisk'), +('External SSD', 'External solid state drive', 'harddisk'), +('Card Reader', 'SD/CF card reader', 'sdcard'), +('Optical Drive', 'External CD/DVD/Blu-ray drive', 'disc'), +('WiFi Adapter', 'USB wireless network adapter', 'wifi'), +('Other', 'Other USB device', 'usb'); +``` + +## Notes +- USB Device equipment type has been deactivated (isactive=0) +- New USB devices should be created in the USB Devices section +- USB devices do NOT need map positions (no mapleft/maptop) + +## Date Created +2026-01-27 diff --git a/migrations/PRODUCTION_MIGRATION_GUIDE.md b/migrations/PRODUCTION_MIGRATION_GUIDE.md new file mode 100644 index 00000000..3f3874a6 --- /dev/null +++ b/migrations/PRODUCTION_MIGRATION_GUIDE.md @@ -0,0 +1,165 @@ +# Production Migration Guide + +## Overview +This guide documents the process for migrating data from the legacy VBScript ShopDB site to the new Flask-based ShopDB. + +## Database Architecture + +### Legacy System (VBScript) +- Single `machines` table containing all equipment, PCs, printers +- `machinetypes` table for classification +- `models` table with `machinetypeid` reference + +### New System (Flask) +- Core `assets` table (unified asset registry) +- Plugin-specific extension tables: + - `equipment` (equipmenttypeid → equipmenttypes) + - `computers` (computertypeid → computertypes) + - `printers` (printertypeid → printertypes) + - `network_device` (networkdevicetypeid → networkdevicetypes) + +## Key Mappings + +### Asset Type Mapping +| Legacy Category | New Asset Type | Extension Table | +|-----------------|---------------|-----------------| +| Equipment machines | Equipment | equipment | +| PC/Computer | Computer | computers | +| Printer | Printer | printers | +| Network (IDF, Switch, AP) | Network Device | network_device | + +### Type ID Alignment +The `equipmenttypes` table IDs match `machinetypes` IDs for easy migration: +- equipmenttypeid = machinetypeid (where applicable) + +## Migration Steps + +### Step 1: Export from Legacy Database +```sql +-- Export machines with all related data +SELECT + m.*, + mt.machinetype, + mo.modelnumber, + mo.machinetypeid as model_typeid, + v.vendor, + bu.businessunit, + s.status +FROM machines m +LEFT JOIN machinetypes mt ON mt.machinetypeid = m.machinetypeid +LEFT JOIN models mo ON mo.modelnumberid = m.modelnumberid +LEFT JOIN vendors v ON v.vendorid = m.vendorid +LEFT JOIN businessunits bu ON bu.businessunitid = m.businessunitid +LEFT JOIN statuses s ON s.statusid = m.statusid; +``` + +### Step 2: Create Assets +For each machine, create an asset record: +```sql +INSERT INTO assets (assetnumber, name, assettypeid, statusid, locationid, businessunitid, mapleft, maptop) +SELECT + CONCAT(machinenumber, '-', machineid), -- Unique asset number + alias, + CASE + WHEN category = 'PC' THEN 2 -- Computer + WHEN category = 'Printer' THEN 4 -- Printer + ELSE 1 -- Equipment + END, + statusid, + locationid, + businessunitid, + mapleft, + maptop +FROM machines; +``` + +### Step 3: Create Extension Records +```sql +-- For Equipment +INSERT INTO equipment (assetid, equipmenttypeid, vendorid, modelnumberid) +SELECT + a.assetid, + COALESCE(mo.machinetypeid, m.machinetypeid), -- Use model's type if available! + m.vendorid, + m.modelnumberid +FROM machines m +JOIN assets a ON a.assetnumber = CONCAT(m.machinenumber, '-', m.machineid) +LEFT JOIN models mo ON mo.modelnumberid = m.modelnumberid +WHERE m.category = 'Equipment' OR m.category IS NULL; +``` + +### Step 4: Post-Migration Fixes + +#### Fix LocationOnly Equipment Types +Equipment imported with LocationOnly type should inherit type from their model: +```sql +-- Fix equipment that has a model with a proper type +UPDATE equipment e +JOIN assets a ON a.assetid = e.assetid +JOIN machines m ON m.machinenumber = SUBSTRING_INDEX(a.assetnumber, '-', 1) +JOIN models mo ON mo.modelnumberid = m.modelnumberid +SET e.equipmenttypeid = mo.machinetypeid +WHERE e.equipmenttypeid = 1 -- LocationOnly +AND mo.machinetypeid != 1; -- Model has real type +``` + +## Validation Queries + +### Check for Orphaned Assets +```sql +SELECT a.* FROM assets a +LEFT JOIN equipment e ON e.assetid = a.assetid +LEFT JOIN computers c ON c.assetid = a.assetid +LEFT JOIN printers p ON p.assetid = a.assetid +WHERE a.assettypeid = 1 -- Equipment type +AND e.assetid IS NULL; +``` + +### Check LocationOnly with Models +```sql +-- Should return 0 after migration fix +SELECT COUNT(*) +FROM equipment e +JOIN assets a ON a.assetid = e.assetid +JOIN machines m ON m.machinenumber = SUBSTRING_INDEX(a.assetnumber, '-', 1) +JOIN models mo ON mo.modelnumberid = m.modelnumberid +WHERE e.equipmenttypeid = 1 +AND mo.machinetypeid != 1; +``` + +### Verify Type Distribution +```sql +SELECT et.equipmenttype, COUNT(*) as count +FROM equipment e +JOIN equipmenttypes et ON et.equipmenttypeid = e.equipmenttypeid +GROUP BY et.equipmenttype +ORDER BY count DESC; +``` + +## Common Issues + +### Issue: Equipment marked as LocationOnly but has model +**Cause**: Migration copied machinetypeid from machines table instead of using model's type +**Fix**: See FIX_LOCATIONONLY_EQUIPMENT_TYPES.md + +### Issue: Missing model relationship +**Cause**: Equipment table modelnumberid not populated during migration +**Fix**: Link through machines table using asset number pattern + +### Issue: Duplicate asset numbers +**Cause**: Asset number generation didn't account for existing duplicates +**Fix**: Use unique suffix or check before insert + +## Scripts Location +- `/migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md` - Fix for LocationOnly type issue +- `/scripts/import_from_mysql.py` - Original import script (may need updates) +- `/scripts/migration/` - Migration utilities + +## Notes +- Always backup before running migration fixes +- Test on staging/dev before production +- Verify counts before and after each fix +- Keep legacy `machines` table for reference during transition + +## Date Created +2026-01-27 diff --git a/migrations/README b/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 00000000..0c5f7bc4 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,169 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.schema import CreateTable + + +# Force every table the migrations create on MySQL to utf8mb4 + DYNAMIC row +# format. Without this a fresh `flask db upgrade` inherits the server default +# charset, so a box whose default is latin1 (common on older MySQL) silently +# builds a latin1 schema that drifts from the utf8mb4 production target. The +# DYNAMIC row format also keeps utf8mb4 indexes under the 767-byte prefix limit +# on pre-5.7 InnoDB. Scoped to the mysql dialect so the SQLite test DB is +# untouched. +@compiles(CreateTable, "mysql") +def _mysql_create_table_utf8mb4(element, compiler, **kw): + sql = compiler.visit_create_table(element, **kw) + if "CHARSET" not in sql.upper(): + sql = sql.rstrip().rstrip(";") + sql += ( + " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + " COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC" + ) + return sql + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + # Alembic creates alembic_version.version_num as VARCHAR(32), but some + # revision ids in this chain exceed 32 chars. On MySQL that truncates the + # stored id, so the next migration's version bump matches 0 rows and the + # upgrade dies. Force the column wide in its OWN committed connection first + # (keeping it out of alembic's migration transaction), so long ids never + # truncate - fresh or existing. + try: + with connectable.connect() as prep: + prep.exec_driver_sql( + 'CREATE TABLE IF NOT EXISTS alembic_version ' + '(version_num VARCHAR(128) NOT NULL, ' + 'CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num))' + ) + prep.exec_driver_sql( + 'ALTER TABLE alembic_version MODIFY version_num VARCHAR(128) NOT NULL' + ) + prep.commit() + except Exception: + # Non-MySQL backends (e.g. sqlite in tests) - alembic's default is fine. + pass + + with connectable.connect() as connection: + # Relax the session sql_mode for the migration run. Some historical + # migrations seed reference rows with raw INSERTs that omit NOT-NULL + # timestamp columns (the ORM supplies those via Python defaults at + # runtime, but a raw migration INSERT does not). MySQL 5.x's lax default + # accepted that; strict MySQL 8 rejects it with 1364 "Field 'createddate' + # doesn't have a default value". Dropping STRICT_TRANS_TABLES for the + # migration session only (the app's own connections keep their mode) + # makes the chain portable across MySQL versions. + try: + connection.exec_driver_sql( + "SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION'") + except Exception: + pass # non-MySQL backends (e.g. sqlite in tests) + + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/rename_underscore_columns.sql b/migrations/rename_underscore_columns.sql new file mode 100644 index 00000000..eeb3d362 --- /dev/null +++ b/migrations/rename_underscore_columns.sql @@ -0,0 +1,23 @@ +-- Rename database columns to remove underscores per naming convention +-- Date: 2026-02-03 +-- Affects: assettypes, assetrelationships, equipment, usbcheckouts + +-- 1. assettypes table +ALTER TABLE assettypes CHANGE COLUMN plugin_name pluginname VARCHAR(100) NULL COMMENT 'Plugin that owns this type'; +ALTER TABLE assettypes CHANGE COLUMN table_name tablename VARCHAR(100) NULL COMMENT 'Extension table name for this type'; + +-- 2. assetrelationships table +ALTER TABLE assetrelationships CHANGE COLUMN source_assetid sourceassetid INT NOT NULL; +ALTER TABLE assetrelationships CHANGE COLUMN target_assetid targetassetid INT NOT NULL; + +-- 3. equipment table +ALTER TABLE equipment CHANGE COLUMN controller_vendorid controllervendorid INT NULL COMMENT 'Controller vendor (e.g., FANUC)'; +ALTER TABLE equipment CHANGE COLUMN controller_modelid controllermodelid INT NULL COMMENT 'Controller model (e.g., 31B)'; + +-- 4. usbcheckouts table +ALTER TABLE usbcheckouts CHANGE COLUMN checkout_name checkoutname VARCHAR(100) NULL COMMENT 'Name of user'; +ALTER TABLE usbcheckouts CHANGE COLUMN checkout_time checkouttime DATETIME NOT NULL; +ALTER TABLE usbcheckouts CHANGE COLUMN checkin_time checkintime DATETIME NULL; +ALTER TABLE usbcheckouts CHANGE COLUMN checkout_reason checkoutreason TEXT NULL COMMENT 'Reason for checkout'; +ALTER TABLE usbcheckouts CHANGE COLUMN checkin_notes checkinnotes TEXT NULL; +ALTER TABLE usbcheckouts CHANGE COLUMN was_wiped waswiped TINYINT(1) NULL COMMENT 'Was device wiped after return'; diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/68b3947ae14f_baseline_schema.py b/migrations/versions/68b3947ae14f_baseline_schema.py new file mode 100644 index 00000000..faab40d5 --- /dev/null +++ b/migrations/versions/68b3947ae14f_baseline_schema.py @@ -0,0 +1,956 @@ +"""baseline schema + +Revision ID: 68b3947ae14f +Revises: +Create Date: 2026-05-08 17:53:08.342776 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '68b3947ae14f' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('appowners', + sa.Column('appownerid', sa.Integer(), nullable=False), + sa.Column('appowner', sa.String(length=100), nullable=False), + sa.Column('sso', sa.String(length=50), nullable=True), + sa.Column('email', sa.String(length=100), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('appownerid') + ) + op.create_table('assetstatuses', + sa.Column('statusid', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=20), nullable=True, comment='CSS color for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('statusid'), + sa.UniqueConstraint('status') + ) + op.create_table('assettypes', + sa.Column('assettypeid', sa.Integer(), nullable=False), + sa.Column('assettype', sa.String(length=50), nullable=False, comment='Category name: equipment, computer, network_device, printer'), + sa.Column('pluginname', sa.String(length=100), nullable=True, comment='Plugin that owns this type'), + sa.Column('tablename', sa.String(length=100), nullable=True, comment='Extension table name for this type'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('assettypeid'), + sa.UniqueConstraint('assettype') + ) + op.create_table('businessunits', + sa.Column('businessunitid', sa.Integer(), nullable=False), + sa.Column('businessunit', sa.String(length=100), nullable=False), + sa.Column('code', sa.String(length=20), nullable=True, comment='Short code'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('parentid', sa.Integer(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['parentid'], ['businessunits.businessunitid'], ), + sa.PrimaryKeyConstraint('businessunitid'), + sa.UniqueConstraint('businessunit'), + sa.UniqueConstraint('code') + ) + op.create_table('communicationtypes', + sa.Column('comtypeid', sa.Integer(), nullable=False), + sa.Column('comtype', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('comtypeid'), + sa.UniqueConstraint('comtype') + ) + op.create_table('computertypes', + sa.Column('computertypeid', sa.Integer(), nullable=False), + sa.Column('computertype', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('computertypeid'), + sa.UniqueConstraint('computertype') + ) + op.create_table('equipmenttypes', + sa.Column('equipmenttypeid', sa.Integer(), nullable=False), + sa.Column('equipmenttype', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('equipmenttypeid'), + sa.UniqueConstraint('equipmenttype') + ) + op.create_table('locations', + sa.Column('locationid', sa.Integer(), nullable=False), + sa.Column('locationname', sa.String(length=100), nullable=False), + sa.Column('building', sa.String(length=100), nullable=True), + sa.Column('floor', sa.String(length=50), nullable=True), + sa.Column('room', sa.String(length=50), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('mapimage', sa.String(length=500), nullable=True, comment='Path to floor map image'), + sa.Column('mapwidth', sa.Integer(), nullable=True), + sa.Column('mapheight', sa.Integer(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('locationid'), + sa.UniqueConstraint('locationname') + ) + op.create_table('machinestatuses', + sa.Column('statusid', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=20), nullable=True, comment='CSS color for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('statusid'), + sa.UniqueConstraint('status') + ) + op.create_table('machinetypes', + sa.Column('machinetypeid', sa.Integer(), nullable=False), + sa.Column('machinetype', sa.String(length=100), nullable=False), + sa.Column('category', sa.String(length=50), nullable=False, comment='Equipment, PC, Network, or Printer'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('machinetypeid'), + sa.UniqueConstraint('machinetype') + ) + op.create_table('networkdevicetypes', + sa.Column('networkdevicetypeid', sa.Integer(), nullable=False), + sa.Column('networkdevicetype', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('networkdevicetypeid'), + sa.UniqueConstraint('networkdevicetype') + ) + op.create_table('notificationtypes', + sa.Column('notificationtypeid', sa.Integer(), nullable=False), + sa.Column('typename', sa.String(length=50), nullable=False), + sa.Column('typedescription', sa.Text(), nullable=True), + sa.Column('typecolor', sa.String(length=20), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('notificationtypeid') + ) + op.create_table('operatingsystems', + sa.Column('osid', sa.Integer(), nullable=False), + sa.Column('osname', sa.String(length=100), nullable=False), + sa.Column('osversion', sa.String(length=50), nullable=True), + sa.Column('architecture', sa.String(length=20), nullable=True, comment='x86, x64, ARM'), + sa.Column('endoflife', sa.Date(), nullable=True, comment='End of support date'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('osid'), + sa.UniqueConstraint('osname', 'osversion', name='uq_os_name_version') + ) + op.create_table('pctypes', + sa.Column('pctypeid', sa.Integer(), nullable=False), + sa.Column('pctype', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('pctypeid'), + sa.UniqueConstraint('pctype') + ) + op.create_table('permissions', + sa.Column('permissionid', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('category', sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint('permissionid'), + sa.UniqueConstraint('name') + ) + op.create_table('printertypes', + sa.Column('printertypeid', sa.Integer(), nullable=False), + sa.Column('printertype', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('printertypeid'), + sa.UniqueConstraint('printertype') + ) + op.create_table('relationshiptypes', + sa.Column('relationshiptypeid', sa.Integer(), nullable=False), + sa.Column('relationshiptype', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('relationshiptypeid'), + sa.UniqueConstraint('relationshiptype') + ) + op.create_table('roles', + sa.Column('roleid', sa.Integer(), nullable=False), + sa.Column('rolename', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('roleid'), + sa.UniqueConstraint('rolename') + ) + op.create_table('settings', + sa.Column('settingid', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('key', sa.String(length=100), nullable=False), + sa.Column('value', sa.Text(), nullable=True), + sa.Column('valuetype', sa.String(length=20), nullable=True), + sa.Column('category', sa.String(length=50), nullable=True), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=True), + sa.Column('modifieddate', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('settingid') + ) + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_settings_key'), ['key'], unique=True) + + op.create_table('usbdevicetypes', + sa.Column('usbdevicetypeid', sa.Integer(), nullable=False), + sa.Column('typename', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('usbdevicetypeid'), + sa.UniqueConstraint('typename') + ) + op.create_table('users', + sa.Column('userid', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=100), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('passwordhash', sa.String(length=255), nullable=False), + sa.Column('firstname', sa.String(length=100), nullable=True), + sa.Column('lastname', sa.String(length=100), nullable=True), + sa.Column('lastlogindate', sa.DateTime(), nullable=True), + sa.Column('failedlogins', sa.Integer(), nullable=True), + sa.Column('lockeduntil', sa.DateTime(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('userid'), + sa.UniqueConstraint('email') + ) + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_users_username'), ['username'], unique=True) + + op.create_table('vendors', + sa.Column('vendorid', sa.Integer(), nullable=False), + sa.Column('vendor', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('website', sa.String(length=255), nullable=True), + sa.Column('supportphone', sa.String(length=50), nullable=True), + sa.Column('supportemail', sa.String(length=100), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('vendorid'), + sa.UniqueConstraint('vendor') + ) + op.create_table('vlans', + sa.Column('vlanid', sa.Integer(), nullable=False), + sa.Column('vlannumber', sa.Integer(), nullable=False, comment='VLAN ID number'), + sa.Column('name', sa.String(length=100), nullable=False, comment='VLAN name'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('vlantype', sa.String(length=50), nullable=True, comment='Type: data, voice, management, guest, etc.'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('vlanid'), + sa.UniqueConstraint('vlannumber') + ) + with op.batch_alter_table('vlans', schema=None) as batch_op: + batch_op.create_index('idx_vlan_number', ['vlannumber'], unique=False) + + op.create_table('assets', + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('assetnumber', sa.String(length=50), nullable=False, comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'), + sa.Column('name', sa.String(length=100), nullable=True, comment='Display name/alias'), + sa.Column('serialnumber', sa.String(length=100), nullable=True, comment='Hardware serial number'), + sa.Column('assettypeid', sa.Integer(), nullable=False), + sa.Column('statusid', sa.Integer(), nullable=True, comment='In Use, Spare, Retired, etc.'), + sa.Column('locationid', sa.Integer(), nullable=True), + sa.Column('businessunitid', sa.Integer(), nullable=True), + sa.Column('mapleft', sa.Integer(), nullable=True, comment='X coordinate on floor map'), + sa.Column('maptop', sa.Integer(), nullable=True, comment='Y coordinate on floor map'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.Column('deleteddate', sa.DateTime(), nullable=True), + sa.Column('deletedby', sa.String(length=100), nullable=True), + sa.Column('createdby', sa.String(length=100), nullable=True), + sa.Column('modifiedby', sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint(['assettypeid'], ['assettypes.assettypeid'], ), + sa.ForeignKeyConstraint(['businessunitid'], ['businessunits.businessunitid'], ), + sa.ForeignKeyConstraint(['locationid'], ['locations.locationid'], ), + sa.ForeignKeyConstraint(['statusid'], ['assetstatuses.statusid'], ), + sa.PrimaryKeyConstraint('assetid') + ) + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.create_index('idx_asset_active', ['isactive'], unique=False) + batch_op.create_index('idx_asset_location', ['locationid'], unique=False) + batch_op.create_index('idx_asset_status', ['statusid'], unique=False) + batch_op.create_index('idx_asset_type_bu', ['assettypeid', 'businessunitid'], unique=False) + batch_op.create_index(batch_op.f('ix_assets_assetnumber'), ['assetnumber'], unique=True) + batch_op.create_index(batch_op.f('ix_assets_serialnumber'), ['serialnumber'], unique=False) + + op.create_table('auditlogs', + sa.Column('auditlogid', sa.Integer(), nullable=False), + sa.Column('userid', sa.Integer(), nullable=True), + sa.Column('username', sa.String(length=100), nullable=True), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.Column('ipaddress', sa.String(length=45), nullable=True), + sa.Column('useragent', sa.String(length=255), nullable=True), + sa.Column('action', sa.String(length=20), nullable=False), + sa.Column('entitytype', sa.String(length=50), nullable=False), + sa.Column('entityid', sa.Integer(), nullable=True), + sa.Column('entityname', sa.String(length=255), nullable=True), + sa.Column('changes', sa.Text(), nullable=True), + sa.Column('details', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['userid'], ['users.userid'], ), + sa.PrimaryKeyConstraint('auditlogid') + ) + with op.batch_alter_table('auditlogs', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_auditlogs_action'), ['action'], unique=False) + batch_op.create_index(batch_op.f('ix_auditlogs_entitytype'), ['entitytype'], unique=False) + batch_op.create_index(batch_op.f('ix_auditlogs_timestamp'), ['timestamp'], unique=False) + + op.create_table('models', + sa.Column('modelnumberid', sa.Integer(), nullable=False), + sa.Column('modelnumber', sa.String(length=100), nullable=False), + sa.Column('machinetypeid', sa.Integer(), nullable=True), + sa.Column('vendorid', sa.Integer(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('imageurl', sa.String(length=500), nullable=True, comment='URL to product image'), + sa.Column('documentationurl', sa.String(length=500), nullable=True, comment='URL to documentation'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['machinetypeid'], ['machinetypes.machinetypeid'], ), + sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ), + sa.PrimaryKeyConstraint('modelnumberid'), + sa.UniqueConstraint('modelnumber', 'vendorid', name='uq_model_vendor') + ) + op.create_table('notifications', + sa.Column('notificationid', sa.Integer(), nullable=False), + sa.Column('notificationtypeid', sa.Integer(), nullable=True), + sa.Column('businessunitid', sa.Integer(), nullable=True), + sa.Column('appid', sa.Integer(), nullable=True), + sa.Column('notification', sa.Text(), nullable=False, comment='The message content'), + sa.Column('starttime', sa.DateTime(), nullable=True), + sa.Column('endtime', sa.DateTime(), nullable=True), + sa.Column('ticketnumber', sa.String(length=50), nullable=True), + sa.Column('link', sa.String(length=500), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=True), + sa.Column('isshopfloor', sa.Boolean(), nullable=True), + sa.Column('employeesso', sa.String(length=100), nullable=True), + sa.Column('employeename', sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint(['notificationtypeid'], ['notificationtypes.notificationtypeid'], ), + sa.PrimaryKeyConstraint('notificationid') + ) + op.create_table('rolepermissions', + sa.Column('roleid', sa.Integer(), nullable=False), + sa.Column('permissionid', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['permissionid'], ['permissions.permissionid'], ), + sa.ForeignKeyConstraint(['roleid'], ['roles.roleid'], ), + sa.PrimaryKeyConstraint('roleid', 'permissionid') + ) + op.create_table('subnets', + sa.Column('subnetid', sa.Integer(), nullable=False), + sa.Column('cidr', sa.String(length=18), nullable=False, comment='CIDR notation (e.g., 10.1.1.0/24)'), + sa.Column('name', sa.String(length=100), nullable=False, comment='Subnet name'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('gatewayip', sa.String(length=15), nullable=True, comment='Default gateway IP address'), + sa.Column('subnetmask', sa.String(length=15), nullable=True, comment='Subnet mask (e.g., 255.255.255.0)'), + sa.Column('networkaddress', sa.String(length=15), nullable=True, comment='Network address (e.g., 10.1.1.0)'), + sa.Column('broadcastaddress', sa.String(length=15), nullable=True, comment='Broadcast address (e.g., 10.1.1.255)'), + sa.Column('vlanid', sa.Integer(), nullable=True), + sa.Column('subnettype', sa.String(length=50), nullable=True, comment='Type: production, development, management, dmz, etc.'), + sa.Column('locationid', sa.Integer(), nullable=True), + sa.Column('dhcpenabled', sa.Boolean(), nullable=True, comment='DHCP enabled for this subnet'), + sa.Column('dhcprangestart', sa.String(length=15), nullable=True, comment='DHCP range start IP'), + sa.Column('dhcprangeend', sa.String(length=15), nullable=True, comment='DHCP range end IP'), + sa.Column('dns1', sa.String(length=15), nullable=True, comment='Primary DNS server'), + sa.Column('dns2', sa.String(length=15), nullable=True, comment='Secondary DNS server'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['locationid'], ['locations.locationid'], ), + sa.ForeignKeyConstraint(['vlanid'], ['vlans.vlanid'], ), + sa.PrimaryKeyConstraint('subnetid'), + sa.UniqueConstraint('cidr') + ) + with op.batch_alter_table('subnets', schema=None) as batch_op: + batch_op.create_index('idx_subnet_cidr', ['cidr'], unique=False) + batch_op.create_index('idx_subnet_location', ['locationid'], unique=False) + batch_op.create_index('idx_subnet_vlan', ['vlanid'], unique=False) + + op.create_table('supportteams', + sa.Column('supportteamid', sa.Integer(), nullable=False), + sa.Column('teamname', sa.String(length=100), nullable=False), + sa.Column('teamurl', sa.String(length=255), nullable=True), + sa.Column('appownerid', sa.Integer(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['appownerid'], ['appowners.appownerid'], ), + sa.PrimaryKeyConstraint('supportteamid') + ) + op.create_table('usbdevices', + sa.Column('usbdeviceid', sa.Integer(), nullable=False), + sa.Column('serialnumber', sa.String(length=100), nullable=False), + sa.Column('label', sa.String(length=100), nullable=True, comment='Human-readable label'), + sa.Column('assetnumber', sa.String(length=50), nullable=True, comment='Optional asset tag'), + sa.Column('usbdevicetypeid', sa.Integer(), nullable=True), + sa.Column('capacitygb', sa.Integer(), nullable=True, comment='Capacity in GB'), + sa.Column('vendorid', sa.String(length=10), nullable=True, comment='USB Vendor ID (hex)'), + sa.Column('productid', sa.String(length=10), nullable=True, comment='USB Product ID (hex)'), + sa.Column('manufacturer', sa.String(length=100), nullable=True), + sa.Column('productname', sa.String(length=100), nullable=True), + sa.Column('ischeckedout', sa.Boolean(), nullable=True), + sa.Column('currentuserid', sa.String(length=50), nullable=True, comment='SSO of current user'), + sa.Column('currentusername', sa.String(length=100), nullable=True, comment='Name of current user'), + sa.Column('currentcheckoutdate', sa.DateTime(), nullable=True), + sa.Column('storagelocation', sa.String(length=200), nullable=True, comment='Where device is stored when not checked out'), + sa.Column('pin', sa.String(length=50), nullable=True, comment='PIN for encrypted devices'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.Column('createdby', sa.String(length=100), nullable=True), + sa.Column('modifiedby', sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint(['usbdevicetypeid'], ['usbdevicetypes.usbdevicetypeid'], ), + sa.PrimaryKeyConstraint('usbdeviceid'), + sa.UniqueConstraint('serialnumber') + ) + with op.batch_alter_table('usbdevices', schema=None) as batch_op: + batch_op.create_index('idx_usb_checkedout', ['ischeckedout'], unique=False) + batch_op.create_index('idx_usb_currentuser', ['currentuserid'], unique=False) + batch_op.create_index('idx_usb_serial', ['serialnumber'], unique=False) + batch_op.create_index('idx_usb_type', ['usbdevicetypeid'], unique=False) + + op.create_table('userroles', + sa.Column('userid', sa.Integer(), nullable=False), + sa.Column('roleid', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['roleid'], ['roles.roleid'], ), + sa.ForeignKeyConstraint(['userid'], ['users.userid'], ), + sa.PrimaryKeyConstraint('userid', 'roleid') + ) + op.create_table('applications', + sa.Column('appid', sa.Integer(), nullable=False), + sa.Column('appname', sa.String(length=100), nullable=False), + sa.Column('appdescription', sa.String(length=255), nullable=True), + sa.Column('supportteamid', sa.Integer(), nullable=True), + sa.Column('isinstallable', sa.Boolean(), nullable=True), + sa.Column('applicationnotes', sa.Text(), nullable=True), + sa.Column('installpath', sa.String(length=255), nullable=True), + sa.Column('applicationlink', sa.String(length=512), nullable=True), + sa.Column('documentationpath', sa.String(length=512), nullable=True), + sa.Column('ishidden', sa.Boolean(), nullable=True), + sa.Column('isprinter', sa.Boolean(), nullable=True), + sa.Column('islicenced', sa.Boolean(), nullable=True), + sa.Column('image', sa.String(length=255), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['supportteamid'], ['supportteams.supportteamid'], ), + sa.PrimaryKeyConstraint('appid'), + sa.UniqueConstraint('appname') + ) + op.create_table('assetrelationships', + sa.Column('relationshipid', sa.Integer(), nullable=False), + sa.Column('sourceassetid', sa.Integer(), nullable=False), + sa.Column('targetassetid', sa.Integer(), nullable=False), + sa.Column('relationshiptypeid', sa.Integer(), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['relationshiptypeid'], ['relationshiptypes.relationshiptypeid'], ), + sa.ForeignKeyConstraint(['sourceassetid'], ['assets.assetid'], ), + sa.ForeignKeyConstraint(['targetassetid'], ['assets.assetid'], ), + sa.PrimaryKeyConstraint('relationshipid'), + sa.UniqueConstraint('sourceassetid', 'targetassetid', 'relationshiptypeid', name='uq_asset_relationship') + ) + with op.batch_alter_table('assetrelationships', schema=None) as batch_op: + batch_op.create_index('idx_asset_rel_source', ['sourceassetid'], unique=False) + batch_op.create_index('idx_asset_rel_target', ['targetassetid'], unique=False) + + op.create_table('computers', + sa.Column('computerid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('computertypeid', sa.Integer(), nullable=True), + sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname'), + sa.Column('osid', sa.Integer(), nullable=True), + sa.Column('loggedinuser', sa.String(length=100), nullable=True), + sa.Column('lastreporteddate', sa.DateTime(), nullable=True), + sa.Column('lastboottime', sa.DateTime(), nullable=True), + sa.Column('isvnc', sa.Boolean(), nullable=True, comment='VNC remote access enabled'), + sa.Column('iswinrm', sa.Boolean(), nullable=True, comment='WinRM enabled'), + sa.Column('isshopfloor', sa.Boolean(), nullable=True, comment='Shopfloor PC (vs office PC)'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['computertypeid'], ['computertypes.computertypeid'], ), + sa.ForeignKeyConstraint(['osid'], ['operatingsystems.osid'], ), + sa.PrimaryKeyConstraint('computerid') + ) + with op.batch_alter_table('computers', schema=None) as batch_op: + batch_op.create_index('idx_computer_hostname', ['hostname'], unique=False) + batch_op.create_index('idx_computer_os', ['osid'], unique=False) + batch_op.create_index('idx_computer_type', ['computertypeid'], unique=False) + batch_op.create_index(batch_op.f('ix_computers_assetid'), ['assetid'], unique=True) + batch_op.create_index(batch_op.f('ix_computers_hostname'), ['hostname'], unique=False) + + op.create_table('equipment', + sa.Column('equipmentid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('equipmenttypeid', sa.Integer(), nullable=True), + sa.Column('vendorid', sa.Integer(), nullable=True), + sa.Column('modelnumberid', sa.Integer(), nullable=True), + sa.Column('requiresmanualconfig', sa.Boolean(), nullable=True, comment='Multi-PC machine needs manual configuration'), + sa.Column('islocationonly', sa.Boolean(), nullable=True, comment='Virtual location marker (not actual equipment)'), + sa.Column('lastmaintenancedate', sa.DateTime(), nullable=True), + sa.Column('nextmaintenancedate', sa.DateTime(), nullable=True), + sa.Column('maintenanceintervaldays', sa.Integer(), nullable=True), + sa.Column('controllervendorid', sa.Integer(), nullable=True, comment='Controller vendor (e.g., FANUC)'), + sa.Column('controllermodelid', sa.Integer(), nullable=True, comment='Controller model (e.g., 31B)'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['controllermodelid'], ['models.modelnumberid'], ), + sa.ForeignKeyConstraint(['controllervendorid'], ['vendors.vendorid'], ), + sa.ForeignKeyConstraint(['equipmenttypeid'], ['equipmenttypes.equipmenttypeid'], ), + sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid'], ), + sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ), + sa.PrimaryKeyConstraint('equipmentid') + ) + with op.batch_alter_table('equipment', schema=None) as batch_op: + batch_op.create_index('idx_equipment_type', ['equipmenttypeid'], unique=False) + batch_op.create_index('idx_equipment_vendor', ['vendorid'], unique=False) + batch_op.create_index(batch_op.f('ix_equipment_assetid'), ['assetid'], unique=True) + + op.create_table('machines', + sa.Column('machineid', sa.Integer(), nullable=False), + sa.Column('machinenumber', sa.String(length=50), nullable=False, comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'), + sa.Column('alias', sa.String(length=100), nullable=True, comment='Friendly name'), + sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname (for PCs)'), + sa.Column('serialnumber', sa.String(length=100), nullable=True, comment='Hardware serial number'), + sa.Column('machinetypeid', sa.Integer(), nullable=False), + sa.Column('pctypeid', sa.Integer(), nullable=True, comment='Set for PCs, NULL for equipment'), + sa.Column('businessunitid', sa.Integer(), nullable=True), + sa.Column('modelnumberid', sa.Integer(), nullable=True), + sa.Column('vendorid', sa.Integer(), nullable=True), + sa.Column('statusid', sa.Integer(), nullable=True, comment='In Use, Spare, Retired, etc.'), + sa.Column('locationid', sa.Integer(), nullable=True), + sa.Column('mapleft', sa.Integer(), nullable=True, comment='X coordinate on floor map'), + sa.Column('maptop', sa.Integer(), nullable=True, comment='Y coordinate on floor map'), + sa.Column('islocationonly', sa.Boolean(), nullable=True, comment='Virtual location marker (not actual machine)'), + sa.Column('osid', sa.Integer(), nullable=True), + sa.Column('loggedinuser', sa.String(length=100), nullable=True), + sa.Column('lastreporteddate', sa.DateTime(), nullable=True), + sa.Column('lastboottime', sa.DateTime(), nullable=True), + sa.Column('isvnc', sa.Boolean(), nullable=True, comment='VNC remote access enabled'), + sa.Column('iswinrm', sa.Boolean(), nullable=True, comment='WinRM enabled'), + sa.Column('isshopfloor', sa.Boolean(), nullable=True, comment='Shopfloor PC'), + sa.Column('requiresmanualconfig', sa.Boolean(), nullable=True, comment='Multi-PC machine needs manual configuration'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.Column('deleteddate', sa.DateTime(), nullable=True), + sa.Column('deletedby', sa.String(length=100), nullable=True), + sa.Column('createdby', sa.String(length=100), nullable=True), + sa.Column('modifiedby', sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint(['businessunitid'], ['businessunits.businessunitid'], ), + sa.ForeignKeyConstraint(['locationid'], ['locations.locationid'], ), + sa.ForeignKeyConstraint(['machinetypeid'], ['machinetypes.machinetypeid'], ), + sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid'], ), + sa.ForeignKeyConstraint(['osid'], ['operatingsystems.osid'], ), + sa.ForeignKeyConstraint(['pctypeid'], ['pctypes.pctypeid'], ), + sa.ForeignKeyConstraint(['statusid'], ['machinestatuses.statusid'], ), + sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ), + sa.PrimaryKeyConstraint('machineid') + ) + with op.batch_alter_table('machines', schema=None) as batch_op: + batch_op.create_index('idx_machine_active', ['isactive'], unique=False) + batch_op.create_index('idx_machine_hostname', ['hostname'], unique=False) + batch_op.create_index('idx_machine_location', ['locationid'], unique=False) + batch_op.create_index('idx_machine_type_bu', ['machinetypeid', 'businessunitid'], unique=False) + batch_op.create_index(batch_op.f('ix_machines_hostname'), ['hostname'], unique=False) + batch_op.create_index(batch_op.f('ix_machines_machinenumber'), ['machinenumber'], unique=True) + batch_op.create_index(batch_op.f('ix_machines_serialnumber'), ['serialnumber'], unique=False) + + op.create_table('networkdevices', + sa.Column('networkdeviceid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('networkdevicetypeid', sa.Integer(), nullable=True), + sa.Column('vendorid', sa.Integer(), nullable=True), + sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname'), + sa.Column('firmwareversion', sa.String(length=100), nullable=True), + sa.Column('portcount', sa.Integer(), nullable=True, comment='Number of ports (for switches)'), + sa.Column('ispoe', sa.Boolean(), nullable=True, comment='Power over Ethernet capable'), + sa.Column('ismanaged', sa.Boolean(), nullable=True, comment='Managed device (SNMP, web interface, etc.)'), + sa.Column('rackunit', sa.String(length=20), nullable=True, comment='Rack unit position (e.g., U1, U5)'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['networkdevicetypeid'], ['networkdevicetypes.networkdevicetypeid'], ), + sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ), + sa.PrimaryKeyConstraint('networkdeviceid') + ) + with op.batch_alter_table('networkdevices', schema=None) as batch_op: + batch_op.create_index('idx_netdev_hostname', ['hostname'], unique=False) + batch_op.create_index('idx_netdev_type', ['networkdevicetypeid'], unique=False) + batch_op.create_index('idx_netdev_vendor', ['vendorid'], unique=False) + batch_op.create_index(batch_op.f('ix_networkdevices_assetid'), ['assetid'], unique=True) + batch_op.create_index(batch_op.f('ix_networkdevices_hostname'), ['hostname'], unique=False) + + op.create_table('printers', + sa.Column('printerid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('printertypeid', sa.Integer(), nullable=True), + sa.Column('vendorid', sa.Integer(), nullable=True), + sa.Column('modelnumberid', sa.Integer(), nullable=True), + sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname'), + sa.Column('windowsname', sa.String(length=255), nullable=True, comment='Windows printer name (e.g., \\\\server\\printer)'), + sa.Column('sharename', sa.String(length=100), nullable=True, comment='CSF/share name'), + sa.Column('iscsf', sa.Boolean(), nullable=True, comment='Is CSF printer'), + sa.Column('installpath', sa.String(length=255), nullable=True, comment='Driver install path'), + sa.Column('pin', sa.String(length=20), nullable=True), + sa.Column('iscolor', sa.Boolean(), nullable=True, comment='Color capable'), + sa.Column('isduplex', sa.Boolean(), nullable=True, comment='Duplex capable'), + sa.Column('isnetwork', sa.Boolean(), nullable=True, comment='Network connected'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid'], ), + sa.ForeignKeyConstraint(['printertypeid'], ['printertypes.printertypeid'], ), + sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ), + sa.PrimaryKeyConstraint('printerid') + ) + with op.batch_alter_table('printers', schema=None) as batch_op: + batch_op.create_index('idx_printer_hostname', ['hostname'], unique=False) + batch_op.create_index('idx_printer_type', ['printertypeid'], unique=False) + batch_op.create_index('idx_printer_windowsname', ['windowsname'], unique=False) + batch_op.create_index(batch_op.f('ix_printers_assetid'), ['assetid'], unique=True) + batch_op.create_index(batch_op.f('ix_printers_hostname'), ['hostname'], unique=False) + + op.create_table('usbcheckouts', + sa.Column('checkoutid', sa.Integer(), nullable=False), + sa.Column('usbdeviceid', sa.Integer(), nullable=True), + sa.Column('machineid', sa.Integer(), nullable=False), + sa.Column('sso', sa.String(length=20), nullable=False, comment='SSO of user'), + sa.Column('checkoutname', sa.String(length=100), nullable=True, comment='Name of user'), + sa.Column('checkouttime', sa.DateTime(), nullable=False), + sa.Column('checkintime', sa.DateTime(), nullable=True), + sa.Column('checkoutreason', sa.Text(), nullable=True, comment='Reason for checkout'), + sa.Column('checkinnotes', sa.Text(), nullable=True), + sa.Column('waswiped', sa.Boolean(), nullable=True, comment='Was device wiped after return'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['usbdeviceid'], ['usbdevices.usbdeviceid'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('checkoutid') + ) + op.create_table('appversions', + sa.Column('appversionid', sa.Integer(), nullable=False), + sa.Column('appid', sa.Integer(), nullable=False), + sa.Column('version', sa.String(length=50), nullable=False), + sa.Column('releasedate', sa.Date(), nullable=True), + sa.Column('notes', sa.String(length=255), nullable=True), + sa.Column('dateadded', sa.DateTime(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ), + sa.PrimaryKeyConstraint('appversionid'), + sa.UniqueConstraint('appid', 'version', name='uq_app_version') + ) + op.create_table('communications', + sa.Column('communicationid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=True, comment='FK to assets table (new architecture)'), + sa.Column('machineid', sa.Integer(), nullable=True, comment='DEPRECATED: FK to machines table - use assetid instead'), + sa.Column('comtypeid', sa.Integer(), nullable=False), + sa.Column('ipaddress', sa.String(length=50), nullable=True), + sa.Column('subnetmask', sa.String(length=50), nullable=True), + sa.Column('gateway', sa.String(length=50), nullable=True), + sa.Column('dns1', sa.String(length=50), nullable=True), + sa.Column('dns2', sa.String(length=50), nullable=True), + sa.Column('macaddress', sa.String(length=50), nullable=True), + sa.Column('isdhcp', sa.Boolean(), nullable=True), + sa.Column('comport', sa.String(length=20), nullable=True), + sa.Column('baudrate', sa.Integer(), nullable=True), + sa.Column('databits', sa.Integer(), nullable=True), + sa.Column('stopbits', sa.String(length=10), nullable=True), + sa.Column('parity', sa.String(length=20), nullable=True), + sa.Column('flowcontrol', sa.String(length=20), nullable=True), + sa.Column('port', sa.Integer(), nullable=True), + sa.Column('username', sa.String(length=100), nullable=True), + sa.Column('pathname', sa.String(length=255), nullable=True), + sa.Column('pathname2', sa.String(length=255), nullable=True, comment='Secondary path for dualpath'), + sa.Column('isprimary', sa.Boolean(), nullable=True, comment='Primary communication method'), + sa.Column('ismachinenetwork', sa.Boolean(), nullable=True, comment='On machine network vs office network'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ), + sa.ForeignKeyConstraint(['comtypeid'], ['communicationtypes.comtypeid'], ), + sa.ForeignKeyConstraint(['machineid'], ['machines.machineid'], ), + sa.PrimaryKeyConstraint('communicationid') + ) + with op.batch_alter_table('communications', schema=None) as batch_op: + batch_op.create_index('idx_comm_asset', ['assetid'], unique=False) + batch_op.create_index('idx_comm_ip', ['ipaddress'], unique=False) + batch_op.create_index('idx_comm_machine', ['machineid'], unique=False) + batch_op.create_index(batch_op.f('ix_communications_assetid'), ['assetid'], unique=False) + + op.create_table('knowledgebase', + sa.Column('linkid', sa.Integer(), nullable=False), + sa.Column('appid', sa.Integer(), nullable=True), + sa.Column('shortdescription', sa.String(length=500), nullable=False), + sa.Column('linkurl', sa.String(length=2000), nullable=True), + sa.Column('keywords', sa.String(length=500), nullable=True), + sa.Column('clicks', sa.Integer(), nullable=True), + sa.Column('lastupdated', sa.DateTime(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ), + sa.PrimaryKeyConstraint('linkid') + ) + op.create_table('machinerelationships', + sa.Column('relationshipid', sa.Integer(), nullable=False), + sa.Column('parentmachineid', sa.Integer(), nullable=False), + sa.Column('childmachineid', sa.Integer(), nullable=False), + sa.Column('relationshiptypeid', sa.Integer(), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['childmachineid'], ['machines.machineid'], ), + sa.ForeignKeyConstraint(['parentmachineid'], ['machines.machineid'], ), + sa.ForeignKeyConstraint(['relationshiptypeid'], ['relationshiptypes.relationshiptypeid'], ), + sa.PrimaryKeyConstraint('relationshipid'), + sa.UniqueConstraint('parentmachineid', 'childmachineid', 'relationshiptypeid', name='uq_machine_relationship') + ) + op.create_table('printerdata', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('machineid', sa.Integer(), nullable=False), + sa.Column('windowsname', sa.String(length=255), nullable=True, comment='Windows printer name (e.g., \\\\server\\printer)'), + sa.Column('sharename', sa.String(length=100), nullable=True, comment='CSF/share name'), + sa.Column('iscsf', sa.Boolean(), nullable=True, comment='Is CSF printer'), + sa.Column('installpath', sa.String(length=255), nullable=True, comment='Driver install path'), + sa.Column('pin', sa.String(length=20), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['machineid'], ['machines.machineid'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('printerdata', schema=None) as batch_op: + batch_op.create_index('idx_printerdata_windowsname', ['windowsname'], unique=False) + batch_op.create_index(batch_op.f('ix_printerdata_machineid'), ['machineid'], unique=True) + + op.create_table('computerinstalledapps', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('computerid', sa.Integer(), nullable=False), + sa.Column('appid', sa.Integer(), nullable=False), + sa.Column('appversionid', sa.Integer(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.Column('installeddate', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ), + sa.ForeignKeyConstraint(['appversionid'], ['appversions.appversionid'], ), + sa.ForeignKeyConstraint(['computerid'], ['computers.computerid'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('computerid', 'appid', name='uq_computer_app') + ) + with op.batch_alter_table('computerinstalledapps', schema=None) as batch_op: + batch_op.create_index('idx_compapp_app', ['appid'], unique=False) + batch_op.create_index('idx_compapp_computer', ['computerid'], unique=False) + + op.create_table('installedapps', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('machineid', sa.Integer(), nullable=False), + sa.Column('appid', sa.Integer(), nullable=False), + sa.Column('appversionid', sa.Integer(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.Column('installeddate', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ), + sa.ForeignKeyConstraint(['appversionid'], ['appversions.appversionid'], ), + sa.ForeignKeyConstraint(['machineid'], ['machines.machineid'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('machineid', 'appid', name='uq_machine_app') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('installedapps') + with op.batch_alter_table('computerinstalledapps', schema=None) as batch_op: + batch_op.drop_index('idx_compapp_computer') + batch_op.drop_index('idx_compapp_app') + + op.drop_table('computerinstalledapps') + with op.batch_alter_table('printerdata', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_printerdata_machineid')) + batch_op.drop_index('idx_printerdata_windowsname') + + op.drop_table('printerdata') + op.drop_table('machinerelationships') + op.drop_table('knowledgebase') + with op.batch_alter_table('communications', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_communications_assetid')) + batch_op.drop_index('idx_comm_machine') + batch_op.drop_index('idx_comm_ip') + batch_op.drop_index('idx_comm_asset') + + op.drop_table('communications') + op.drop_table('appversions') + op.drop_table('usbcheckouts') + with op.batch_alter_table('printers', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_printers_hostname')) + batch_op.drop_index(batch_op.f('ix_printers_assetid')) + batch_op.drop_index('idx_printer_windowsname') + batch_op.drop_index('idx_printer_type') + batch_op.drop_index('idx_printer_hostname') + + op.drop_table('printers') + with op.batch_alter_table('networkdevices', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_networkdevices_hostname')) + batch_op.drop_index(batch_op.f('ix_networkdevices_assetid')) + batch_op.drop_index('idx_netdev_vendor') + batch_op.drop_index('idx_netdev_type') + batch_op.drop_index('idx_netdev_hostname') + + op.drop_table('networkdevices') + with op.batch_alter_table('machines', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_machines_serialnumber')) + batch_op.drop_index(batch_op.f('ix_machines_machinenumber')) + batch_op.drop_index(batch_op.f('ix_machines_hostname')) + batch_op.drop_index('idx_machine_type_bu') + batch_op.drop_index('idx_machine_location') + batch_op.drop_index('idx_machine_hostname') + batch_op.drop_index('idx_machine_active') + + op.drop_table('machines') + with op.batch_alter_table('equipment', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_equipment_assetid')) + batch_op.drop_index('idx_equipment_vendor') + batch_op.drop_index('idx_equipment_type') + + op.drop_table('equipment') + with op.batch_alter_table('computers', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_computers_hostname')) + batch_op.drop_index(batch_op.f('ix_computers_assetid')) + batch_op.drop_index('idx_computer_type') + batch_op.drop_index('idx_computer_os') + batch_op.drop_index('idx_computer_hostname') + + op.drop_table('computers') + with op.batch_alter_table('assetrelationships', schema=None) as batch_op: + batch_op.drop_index('idx_asset_rel_target') + batch_op.drop_index('idx_asset_rel_source') + + op.drop_table('assetrelationships') + op.drop_table('applications') + op.drop_table('userroles') + with op.batch_alter_table('usbdevices', schema=None) as batch_op: + batch_op.drop_index('idx_usb_type') + batch_op.drop_index('idx_usb_serial') + batch_op.drop_index('idx_usb_currentuser') + batch_op.drop_index('idx_usb_checkedout') + + op.drop_table('usbdevices') + op.drop_table('supportteams') + with op.batch_alter_table('subnets', schema=None) as batch_op: + batch_op.drop_index('idx_subnet_vlan') + batch_op.drop_index('idx_subnet_location') + batch_op.drop_index('idx_subnet_cidr') + + op.drop_table('subnets') + op.drop_table('rolepermissions') + op.drop_table('notifications') + op.drop_table('models') + with op.batch_alter_table('auditlogs', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_auditlogs_timestamp')) + batch_op.drop_index(batch_op.f('ix_auditlogs_entitytype')) + batch_op.drop_index(batch_op.f('ix_auditlogs_action')) + + op.drop_table('auditlogs') + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_assets_serialnumber')) + batch_op.drop_index(batch_op.f('ix_assets_assetnumber')) + batch_op.drop_index('idx_asset_type_bu') + batch_op.drop_index('idx_asset_status') + batch_op.drop_index('idx_asset_location') + batch_op.drop_index('idx_asset_active') + + op.drop_table('assets') + with op.batch_alter_table('vlans', schema=None) as batch_op: + batch_op.drop_index('idx_vlan_number') + + op.drop_table('vlans') + op.drop_table('vendors') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_users_username')) + + op.drop_table('users') + op.drop_table('usbdevicetypes') + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_settings_key')) + + op.drop_table('settings') + op.drop_table('roles') + op.drop_table('relationshiptypes') + op.drop_table('printertypes') + op.drop_table('permissions') + op.drop_table('pctypes') + op.drop_table('operatingsystems') + op.drop_table('notificationtypes') + op.drop_table('networkdevicetypes') + op.drop_table('machinetypes') + op.drop_table('machinestatuses') + op.drop_table('locations') + op.drop_table('equipmenttypes') + op.drop_table('computertypes') + op.drop_table('communicationtypes') + op.drop_table('businessunits') + op.drop_table('assettypes') + op.drop_table('assetstatuses') + op.drop_table('appowners') + # ### end Alembic commands ### diff --git a/migrations/versions/7a01_adr001_position_contract.py b/migrations/versions/7a01_adr001_position_contract.py new file mode 100644 index 00000000..63b73fb1 --- /dev/null +++ b/migrations/versions/7a01_adr001_position_contract.py @@ -0,0 +1,147 @@ +"""ADR-001 position contract surface + +Rename Asset.mapleft -> Asset.mapx, Asset.maptop -> Asset.mapy. Add +Location.mapx / Location.mapy. Add AssetRelationship.label, +AssetRelationship.inheritsposition, RelationshipType.propagatesthroughid. +Seed the three canonical relationship types (partof, controls, connectedto) +with controls.propagatesthroughid -> partof. + +Revision ID: 7a01_adr001_position +Revises: 68b3947ae14f +Create Date: 2026-05-30 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7a01_adr001_position' +down_revision = '68b3947ae14f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ---- Asset.mapleft/maptop -> mapx/mapy -------------------------------- + with op.batch_alter_table('assets') as batch_op: + batch_op.alter_column('mapleft', new_column_name='mapx', + existing_type=sa.Integer(), existing_nullable=True, + comment='X coordinate on floor map (ADR-001)') + batch_op.alter_column('maptop', new_column_name='mapy', + existing_type=sa.Integer(), existing_nullable=True, + comment='Y coordinate on floor map (ADR-001)') + + # ---- Location: add mapx/mapy for the fallback path -------------------- + with op.batch_alter_table('locations') as batch_op: + batch_op.add_column(sa.Column('mapx', sa.Integer(), nullable=True, + comment='Default X coordinate for assets at this location')) + batch_op.add_column(sa.Column('mapy', sa.Integer(), nullable=True, + comment='Default Y coordinate for assets at this location')) + + # ---- RelationshipType: add propagatesthroughid self-FK ---------------- + with op.batch_alter_table('relationshiptypes') as batch_op: + batch_op.add_column(sa.Column('propagatesthroughid', sa.Integer(), + nullable=True, + comment='Sibling-propagation rail per ADR-001')) + batch_op.create_foreign_key( + 'fk_relationshiptype_propagation', + 'relationshiptypes', + ['propagatesthroughid'], + ['relationshiptypeid'], + ) + + # ---- AssetRelationship: add label + inheritsposition ------------------ + with op.batch_alter_table('assetrelationships') as batch_op: + batch_op.add_column(sa.Column('label', sa.String(length=200), nullable=True, + comment='Free-text relationship description (ADR-001)')) + batch_op.add_column(sa.Column('inheritsposition', sa.Boolean(), + nullable=False, server_default='1', + comment='If true, resolved-position walk follows this edge (ADR-001)')) + + # ---- Seed three canonical relationship types -------------------------- + # Idempotent: insert only if name is not already present. Then update + # controls.propagatesthroughid to point at partof. + relationshiptypes = sa.table( + 'relationshiptypes', + sa.column('relationshiptype', sa.String), + sa.column('description', sa.Text), + sa.column('propagatesthroughid', sa.Integer), + sa.column('isactive', sa.Boolean), + ) + + conn = op.get_bind() + dialect_name = conn.dialect.name + + # MySQL default collation is case-insensitive, which makes a plain + # `WHERE relationshiptype = 'controls'` match a legacy `Controls` row + # and silently skip the insert (then accidentally wire propagation onto + # the wrong row). Force binary comparison on MySQL so the three ADR + # types are always distinct from legacy capitalized names. SQLite + + # PostgreSQL are case-sensitive by default; the plain comparison is + # safe there. + def _eq(col_expr, value): + if dialect_name == 'mysql': + return f"BINARY {col_expr} = '{value}'" + return f"{col_expr} = '{value}'" + + for rt in ( + ('partof', 'Composition / sub-assembly (ADR-001)'), + ('controls', 'Operational authority over another asset (ADR-001)'), + ('connectedto', 'Network or data link without authority (ADR-001)'), + ): + existing = conn.execute(sa.text( + f"SELECT relationshiptypeid FROM relationshiptypes WHERE {_eq('relationshiptype', rt[0])}" + )).first() + if not existing: + conn.execute(relationshiptypes.insert().values( + relationshiptype=rt[0], + description=rt[1], + propagatesthroughid=None, + isactive=True, + )) + + partof_row = conn.execute(sa.text( + f"SELECT relationshiptypeid FROM relationshiptypes WHERE {_eq('relationshiptype', 'partof')}" + )).first() + if partof_row: + conn.execute(sa.text( + f"UPDATE relationshiptypes SET propagatesthroughid = :p WHERE {_eq('relationshiptype', 'controls')}" + ), {'p': partof_row[0]}) + + +def downgrade(): + # Revert in reverse order. Drops the seeded ADR types if no rows + # reference them; otherwise leaves them in place to avoid FK violations. + conn = op.get_bind() + + with op.batch_alter_table('assetrelationships') as batch_op: + batch_op.drop_column('inheritsposition') + batch_op.drop_column('label') + + with op.batch_alter_table('relationshiptypes') as batch_op: + batch_op.drop_constraint('fk_relationshiptype_propagation', type_='foreignkey') + batch_op.drop_column('propagatesthroughid') + + with op.batch_alter_table('locations') as batch_op: + batch_op.drop_column('mapy') + batch_op.drop_column('mapx') + + with op.batch_alter_table('assets') as batch_op: + batch_op.alter_column('mapx', new_column_name='mapleft', + existing_type=sa.Integer(), existing_nullable=True) + batch_op.alter_column('mapy', new_column_name='maptop', + existing_type=sa.Integer(), existing_nullable=True) + + # Best-effort: drop the seeded types if nothing references them. + for name in ('connectedto', 'controls', 'partof'): + try: + conn.execute(sa.text( + "DELETE FROM relationshiptypes " + "WHERE relationshiptype = :n " + "AND relationshiptypeid NOT IN (" + " SELECT relationshiptypeid FROM assetrelationships " + " UNION SELECT relationshiptypeid FROM machinerelationships" + ")" + ), {'n': name}) + except Exception: + pass diff --git a/migrations/versions/7b02_gaugelabreference.py b/migrations/versions/7b02_gaugelabreference.py new file mode 100644 index 00000000..165b98b4 --- /dev/null +++ b/migrations/versions/7b02_gaugelabreference.py @@ -0,0 +1,44 @@ +"""Add Asset.gaugelabreference and Asset.maintenancereference + +Adds dedicated external-reference columns to assets, distinct from +assetnumber. Equipment commonly carries an authoritative gauge lab reference +and a maintenance system reference; assetnumber stays the generic asset tag +and name the layperson label. + +Revision ID: 7b02_gaugelabref +Revises: 7a01_adr001_position +Create Date: 2026-06-25 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7b02_gaugelabref' +down_revision = '7a01_adr001_position' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('assets') as batch_op: + batch_op.add_column(sa.Column( + 'gaugelabreference', sa.String(length=50), nullable=True, + comment='Gauge lab asset reference (authoritative tag the gauge ' + 'lab assigns to equipment); distinct from assetnumber')) + batch_op.add_column(sa.Column( + 'maintenancereference', sa.String(length=50), nullable=True, + comment='Maintenance system asset reference; distinct from ' + 'assetnumber')) + batch_op.create_index('ix_assets_gaugelabreference', + ['gaugelabreference']) + batch_op.create_index('ix_assets_maintenancereference', + ['maintenancereference']) + + +def downgrade(): + with op.batch_alter_table('assets') as batch_op: + batch_op.drop_index('ix_assets_maintenancereference') + batch_op.drop_index('ix_assets_gaugelabreference') + batch_op.drop_column('maintenancereference') + batch_op.drop_column('gaugelabreference') diff --git a/migrations/versions/7c01_drop_legacy_machine.py b/migrations/versions/7c01_drop_legacy_machine.py new file mode 100644 index 00000000..b8276383 --- /dev/null +++ b/migrations/versions/7c01_drop_legacy_machine.py @@ -0,0 +1,47 @@ +"""Drop the legacy Machine instance layer + +Retires the Machine model (ADR-001): the asset/computer model is now the +single source of truth. Drops machines + its PC/status lookups + the legacy +relationship and installed-app tables + the legacy printer extension, and +removes the deprecated communications.machineid column. machinetypes is kept +(still referenced by models.machinetypeid). + +Idempotent (IF EXISTS) so it is safe even though the live drop was applied +directly during the cutover. + +Revision ID: 7c01_drop_legacy_machine +Revises: 7b02_gaugelabref +Create Date: 2026-06-26 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7c01_drop_legacy_machine' +down_revision = '7b02_gaugelabref' +branch_labels = None +depends_on = None + +_TABLES = ['printerdata', 'installedapps', 'machinerelationships', + 'machines', 'pctypes', 'machinestatuses'] + + +def upgrade(): + bind = op.get_bind() + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0") + insp = sa.inspect(bind) + if 'machineid' in [c['name'] for c in insp.get_columns('communications')]: + for fk in insp.get_foreign_keys('communications'): + if 'machineid' in fk['constrained_columns'] and fk.get('name'): + bind.exec_driver_sql( + f"ALTER TABLE communications DROP FOREIGN KEY {fk['name']}") + op.drop_column('communications', 'machineid') + for t in _TABLES: + bind.exec_driver_sql(f"DROP TABLE IF EXISTS {t}") + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1") + + +def downgrade(): + # The Machine layer is retired; recreating it is out of scope. + raise NotImplementedError("Legacy Machine layer cannot be restored") diff --git a/migrations/versions/7c02_application_isrequired.py b/migrations/versions/7c02_application_isrequired.py new file mode 100644 index 00000000..b0328995 --- /dev/null +++ b/migrations/versions/7c02_application_isrequired.py @@ -0,0 +1,29 @@ +"""Add applications.isrequired + +Backs the software-compliance report, which compares required applications +against what is installed per PC. There was no such flag before, so the report +errored. + +Revision ID: 7c02_app_isrequired +Revises: 7c01_drop_legacy_machine +Create Date: 2026-06-26 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7c02_app_isrequired' +down_revision = '7c01_drop_legacy_machine' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('applications', sa.Column( + 'isrequired', sa.Boolean(), nullable=True, server_default=sa.false(), + comment='Required on all PCs (drives the software-compliance report)')) + + +def downgrade(): + op.drop_column('applications', 'isrequired') diff --git a/migrations/versions/7c03_locationtypes.py b/migrations/versions/7c03_locationtypes.py new file mode 100644 index 00000000..4b2de0f7 --- /dev/null +++ b/migrations/versions/7c03_locationtypes.py @@ -0,0 +1,56 @@ +"""Add locationtypes + locations tree (ADR-001) + +Creates the locationtypes lookup and extends locations with locationtypeid + +parentlocationid (self-FK), so sites can classify locations and build a +location tree (sections, cells, sub-cells, operations, etc.). Seeds the +canonical location types. + +Revision ID: 7c03_locationtypes +Revises: 7c02_app_isrequired +Create Date: 2026-06-26 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7c03_locationtypes' +down_revision = '7c02_app_isrequired' +branch_labels = None +depends_on = None + +_TYPES = ['section', 'cell', 'subcell', 'operation', 'meetingroom', 'lab', + 'office', 'storage', 'hallway', 'networkcloset', 'building'] + + +def upgrade(): + op.create_table( + 'locationtypes', + sa.Column('locationtypeid', sa.Integer(), primary_key=True), + sa.Column('locationtype', sa.String(length=50), nullable=False, unique=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=True), + sa.Column('modifieddate', sa.DateTime(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=True), + ) + with op.batch_alter_table('locations') as batch_op: + batch_op.add_column(sa.Column('locationtypeid', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('parentlocationid', sa.Integer(), nullable=True)) + batch_op.create_foreign_key('fk_locations_locationtype', 'locationtypes', + ['locationtypeid'], ['locationtypeid']) + batch_op.create_foreign_key('fk_locations_parent', 'locations', + ['parentlocationid'], ['locationid']) + + lt = sa.table('locationtypes', + sa.column('locationtype', sa.String), + sa.column('isactive', sa.Boolean)) + op.bulk_insert(lt, [{'locationtype': t, 'isactive': True} for t in _TYPES]) + + +def downgrade(): + with op.batch_alter_table('locations') as batch_op: + batch_op.drop_constraint('fk_locations_parent', type_='foreignkey') + batch_op.drop_constraint('fk_locations_locationtype', type_='foreignkey') + batch_op.drop_column('parentlocationid') + batch_op.drop_column('locationtypeid') + op.drop_table('locationtypes') diff --git a/migrations/versions/7c04_fold_plugin_schema.py b/migrations/versions/7c04_fold_plugin_schema.py new file mode 100644 index 00000000..d95bd04f --- /dev/null +++ b/migrations/versions/7c04_fold_plugin_schema.py @@ -0,0 +1,85 @@ +"""Fold remaining plugin schema into the core chain + +Deploys run `flask db upgrade` (core chain) only, so the core chain must +reproduce the full bundled schema. The core baseline already creates the +plugin tables as of baseline time; this adds the post-baseline additions that +previously lived only in per-plugin migrations (which deploys don't run): + + - modelsupplies (printer model -> toner/drum/waste part numbers) + - computers.vendorid + computers.modelnumberid (PC make/model) + - computerinstalledapps.installedversion (collector version string) + +Idempotent: skips anything already present, so it is a no-op on the live DB +(which already has these) and creates them on a fresh deploy. + +Revision ID: 7c04_fold_plugin_schema +Revises: 7c03_locationtypes +Create Date: 2026-06-26 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7c04_fold_plugin_schema' +down_revision = '7c03_locationtypes' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + tables = set(insp.get_table_names()) + + if 'modelsupplies' not in tables: + op.create_table( + 'modelsupplies', + sa.Column('modelsupplyid', sa.Integer(), primary_key=True), + sa.Column('modelnumberid', sa.Integer(), + sa.ForeignKey('models.modelnumberid'), nullable=False), + sa.Column('supplytype', sa.String(length=20), nullable=False, + server_default='toner'), + sa.Column('color', sa.String(length=20), nullable=False, + server_default='none'), + sa.Column('capacitytier', sa.String(length=20), nullable=False, + server_default='standard'), + sa.Column('partnumber', sa.String(length=50), nullable=False), + sa.Column('marketingname', sa.String(length=120), nullable=True), + sa.Column('pageyield', sa.Integer(), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=True), + sa.Column('modifieddate', sa.DateTime(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=True), + sa.UniqueConstraint('modelnumberid', 'partnumber', + name='uq_modelsupply_part'), + ) + op.create_index('idx_modelsupplies_modelnumberid', 'modelsupplies', + ['modelnumberid']) + + comp_cols = {c['name'] for c in insp.get_columns('computers')} + with op.batch_alter_table('computers') as batch_op: + if 'vendorid' not in comp_cols: + batch_op.add_column(sa.Column('vendorid', sa.Integer(), nullable=True)) + batch_op.create_foreign_key('fk_computers_vendor', 'vendors', + ['vendorid'], ['vendorid']) + if 'modelnumberid' not in comp_cols: + batch_op.add_column(sa.Column('modelnumberid', sa.Integer(), nullable=True)) + batch_op.create_foreign_key('fk_computers_model', 'models', + ['modelnumberid'], ['modelnumberid']) + + cia_cols = {c['name'] for c in insp.get_columns('computerinstalledapps')} + if 'installedversion' not in cia_cols: + op.add_column('computerinstalledapps', + sa.Column('installedversion', sa.String(length=100), + nullable=True)) + + +def downgrade(): + op.drop_table('modelsupplies') + with op.batch_alter_table('computers') as batch_op: + batch_op.drop_constraint('fk_computers_model', type_='foreignkey') + batch_op.drop_constraint('fk_computers_vendor', type_='foreignkey') + batch_op.drop_column('modelnumberid') + batch_op.drop_column('vendorid') + op.drop_column('computerinstalledapps', 'installedversion') diff --git a/migrations/versions/7d01_dashboarddefaults.py b/migrations/versions/7d01_dashboarddefaults.py new file mode 100644 index 00000000..d7e9c6ea --- /dev/null +++ b/migrations/versions/7d01_dashboarddefaults.py @@ -0,0 +1,37 @@ +"""Add dashboarddefaults (visitor-IP -> business-unit mapping) + +A shopfloor / lobby kiosk resolves which business unit to display from the +display PC's IP. Powers the visitor-location lookup. + +Revision ID: 7d01_dashboarddefaults +Revises: 7c04_fold_plugin_schema +Create Date: 2026-06-27 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d01_dashboarddefaults' +down_revision = '7c04_fold_plugin_schema' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'dashboarddefaults', + sa.Column('dashboarddefaultid', sa.Integer(), primary_key=True), + sa.Column('ipaddress', sa.String(length=50), nullable=False, unique=True), + sa.Column('businessunitid', sa.Integer(), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=True), + sa.Column('modifieddate', sa.DateTime(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint(['businessunitid'], ['businessunits.businessunitid'], + name='fk_dashboarddefaults_businessunit'), + ) + + +def downgrade(): + op.drop_table('dashboarddefaults') diff --git a/migrations/versions/7d02_widen_notification_employee_cols.py b/migrations/versions/7d02_widen_notification_employee_cols.py new file mode 100644 index 00000000..5ae506ab --- /dev/null +++ b/migrations/versions/7d02_widen_notification_employee_cols.py @@ -0,0 +1,37 @@ +"""Widen notifications.employeesso / employeename to TEXT + +Recognition and recertification notifications comma-join every listed +employee's SSO and name into a single column. VARCHAR(100) truncated the list +at ~11 people, dropping names off the shopfloor grid. TEXT removes the cap. + +Revision ID: 7d02_widen_notification_employee_cols +Revises: 7d01_dashboarddefaults +Create Date: 2026-07-07 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d02_widen_notification_employee_cols' +down_revision = '7d01_dashboarddefaults' +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column('notifications', 'employeesso', + existing_type=sa.String(length=100), type_=sa.Text(), + existing_nullable=True) + op.alter_column('notifications', 'employeename', + existing_type=sa.String(length=100), type_=sa.Text(), + existing_nullable=True) + + +def downgrade(): + op.alter_column('notifications', 'employeesso', + existing_type=sa.Text(), type_=sa.String(length=100), + existing_nullable=True) + op.alter_column('notifications', 'employeename', + existing_type=sa.Text(), type_=sa.String(length=100), + existing_nullable=True) diff --git a/migrations/versions/7d03_notificationtype_expiry.py b/migrations/versions/7d03_notificationtype_expiry.py new file mode 100644 index 00000000..07f2125f --- /dev/null +++ b/migrations/versions/7d03_notificationtype_expiry.py @@ -0,0 +1,53 @@ +"""Per-type auto-expiry rule on notificationtypes + +Makes the shopfloor auto-expiry window configurable per notification type +instead of hardcoding recognition (8 AM Eastern) and recertification (14 days) +in the API. Adds: + expirymode 'none' | 'duration' | 'dailytime' + expirydays days for 'duration' + expiryhour hour (Eastern) for 'dailytime' + expiryminute minute (Eastern) for 'dailytime' + +Seeds the two existing rule-bearing types so behavior is unchanged: + Recognition (typecolor 'recognition') -> dailytime 08:00 Eastern + Recertification (typecolor 'recertification') -> duration 14 days + +Revision ID: 7d03_notificationtype_expiry +Revises: 7d02_widen_notification_employee_cols +Create Date: 2026-07-08 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d03_notificationtype_expiry' +down_revision = '7d02_widen_notification_employee_cols' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('notificationtypes', sa.Column('expirymode', sa.String(length=20), nullable=True, server_default='none')) + op.add_column('notificationtypes', sa.Column('expirydays', sa.Integer(), nullable=True)) + op.add_column('notificationtypes', sa.Column('expiryhour', sa.SmallInteger(), nullable=True)) + op.add_column('notificationtypes', sa.Column('expiryminute', sa.SmallInteger(), nullable=True, server_default='0')) + + # preserve current behavior for the two rule-bearing types + op.execute( + "UPDATE notificationtypes SET expirymode='dailytime', expiryhour=8, expiryminute=0 " + "WHERE typecolor='recognition'" + ) + op.execute( + "UPDATE notificationtypes SET expirymode='duration', expirydays=14 " + "WHERE typecolor='recertification'" + ) + # everything else: explicit 'none' (indefinite unless the creator sets an end time) + op.execute("UPDATE notificationtypes SET expirymode='none' WHERE expirymode IS NULL") + + +def downgrade(): + op.drop_column('notificationtypes', 'expiryminute') + op.drop_column('notificationtypes', 'expiryhour') + op.drop_column('notificationtypes', 'expirydays') + op.drop_column('notificationtypes', 'expirymode') diff --git a/migrations/versions/7d04_notificationtype_display.py b/migrations/versions/7d04_notificationtype_display.py new file mode 100644 index 00000000..93da26b8 --- /dev/null +++ b/migrations/versions/7d04_notificationtype_display.py @@ -0,0 +1,43 @@ +"""Add data-driven shopfloor display behavior to notification types + +Replaces the hardcoded recognition/training/recertification logic with per-type +columns: split one card per employee, show employee photo, and the display +style (standard rows / carousel / grid / banner). Seeds the built-in special +types by their typecolor keyword. + +Revision ID: 7d04_notificationtype_display +Revises: 7d03_notificationtype_expiry +Create Date: 2026-07-08 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d04_notificationtype_display' +down_revision = '7d03_notificationtype_expiry' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('notificationtypes', sa.Column('splitperemployee', sa.Boolean(), nullable=True, server_default='0')) + op.add_column('notificationtypes', sa.Column('showemployeephoto', sa.Boolean(), nullable=True, server_default='0')) + op.add_column('notificationtypes', sa.Column('displaystyle', sa.String(length=20), nullable=True, server_default='standard')) + + conn = op.get_bind() + conn.execute(sa.text( + "UPDATE notificationtypes SET splitperemployee=1, showemployeephoto=1, displaystyle='carousel' " + "WHERE typecolor='recognition'")) + conn.execute(sa.text( + "UPDATE notificationtypes SET splitperemployee=1, showemployeephoto=1, displaystyle='grid' " + "WHERE typecolor='recertification'")) + conn.execute(sa.text( + "UPDATE notificationtypes SET splitperemployee=1, showemployeephoto=1, displaystyle='carousel' " + "WHERE typecolor='training'")) + + +def downgrade(): + op.drop_column('notificationtypes', 'displaystyle') + op.drop_column('notificationtypes', 'showemployeephoto') + op.drop_column('notificationtypes', 'splitperemployee') diff --git a/migrations/versions/7d05_pc_access_protocols.py b/migrations/versions/7d05_pc_access_protocols.py new file mode 100644 index 00000000..375394f5 --- /dev/null +++ b/migrations/versions/7d05_pc_access_protocols.py @@ -0,0 +1,76 @@ +"""PC access protocols: catalog + per-PC links, retire isvnc/iswinrm + +Adds an admin-managed protocol catalog (accessprotocols) and a per-PC link +table (computeraccess). Seeds VNC/WinRM/RDP, migrates each PC's isvnc/iswinrm +into computeraccess rows, then drops the two boolean columns. + +Revision ID: 7d05_pc_access_protocols +Revises: 7d04_notificationtype_display +Create Date: 2026-07-08 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d05_pc_access_protocols' +down_revision = '7d04_notificationtype_display' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'accessprotocols', + sa.Column('protocolid', sa.Integer(), primary_key=True), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('scheme', sa.String(length=20), nullable=False), + sa.Column('defaultport', sa.Integer(), nullable=True), + sa.Column('linktemplate', sa.String(length=255), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'), + sa.UniqueConstraint('name', name='uq_accessprotocol_name'), + ) + op.create_table( + 'computeraccess', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('computerid', sa.Integer(), nullable=False), + sa.Column('protocolid', sa.Integer(), nullable=False), + sa.Column('portoverride', sa.Integer(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'), + sa.ForeignKeyConstraint(['computerid'], ['computers.computerid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['protocolid'], ['accessprotocols.protocolid']), + sa.UniqueConstraint('computerid', 'protocolid', name='uq_computer_protocol'), + ) + op.create_index('idx_compaccess_computer', 'computeraccess', ['computerid']) + + conn = op.get_bind() + # Seed the protocol catalog. exec_driver_sql so the ':' / '{}' in the + # templates are not parsed as bind params. + conn.exec_driver_sql( + "INSERT INTO accessprotocols (name, scheme, defaultport, linktemplate, isactive) VALUES " + "('VNC','vnc',5900,'vnc://{host}:{port}',1)," + "('WinRM','https',5986,'https://{host}:{port}/wsman',1)," + "('RDP','rdp',3389,'rdp://{host}:{port}',1)" + ) + # Migrate the old booleans into per-PC access rows. + conn.exec_driver_sql( + "INSERT INTO computeraccess (computerid, protocolid, isactive) " + "SELECT c.computerid, p.protocolid, 1 FROM computers c " + "JOIN accessprotocols p ON p.name='VNC' WHERE c.isvnc = 1" + ) + conn.exec_driver_sql( + "INSERT INTO computeraccess (computerid, protocolid, isactive) " + "SELECT c.computerid, p.protocolid, 1 FROM computers c " + "JOIN accessprotocols p ON p.name='WinRM' WHERE c.iswinrm = 1" + ) + + op.drop_column('computers', 'isvnc') + op.drop_column('computers', 'iswinrm') + + +def downgrade(): + op.add_column('computers', sa.Column('iswinrm', sa.Boolean(), nullable=True)) + op.add_column('computers', sa.Column('isvnc', sa.Boolean(), nullable=True)) + op.drop_index('idx_compaccess_computer', table_name='computeraccess') + op.drop_table('computeraccess') + op.drop_table('accessprotocols') diff --git a/migrations/versions/7d06_typecolor_to_hex.py b/migrations/versions/7d06_typecolor_to_hex.py new file mode 100644 index 00000000..e06eb116 --- /dev/null +++ b/migrations/versions/7d06_typecolor_to_hex.py @@ -0,0 +1,46 @@ +"""Give the built-in notification types real hex colors + +Recognition/Recertification/Training stored keyword typecolors ('recognition' +etc.) that static color maps had to translate. Every other type already stores +a hex. This converts the three to hex so color is fully data-driven and the +static maps can go away. Their special behavior now rides on displaystyle / +splitperemployee / showemployeephoto, not the color keyword. + +Revision ID: 7d06_typecolor_to_hex +Revises: 7d05_pc_access_protocols +Create Date: 2026-07-08 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d06_typecolor_to_hex' +down_revision = '7d05_pc_access_protocols' +branch_labels = None +depends_on = None + + +KEYWORD_HEX = { + 'recognition': '#ffc107', + 'recertification': '#0d6efd', + 'training': '#17a2b8', +} + + +def upgrade(): + conn = op.get_bind() + for keyword, hexcolor in KEYWORD_HEX.items(): + conn.execute( + sa.text("UPDATE notificationtypes SET typecolor = :hex WHERE typecolor = :kw"), + {'hex': hexcolor, 'kw': keyword} + ) + + +def downgrade(): + conn = op.get_bind() + for keyword, hexcolor in KEYWORD_HEX.items(): + conn.execute( + sa.text("UPDATE notificationtypes SET typecolor = :kw WHERE typecolor = :hex"), + {'kw': keyword, 'hex': hexcolor} + ) diff --git a/migrations/versions/7d07_assettype_color.py b/migrations/versions/7d07_assettype_color.py new file mode 100644 index 00000000..16c7c1c1 --- /dev/null +++ b/migrations/versions/7d07_assettype_color.py @@ -0,0 +1,42 @@ +"""Add color to asset types (data-driven map/type colors) + +Gives AssetType a color column (like AssetStatus) so the map's top-level type +colors come from data instead of the hardcoded map in mapColors.js. Seeds the +four base types with the exact colors that were hardcoded, so nothing changes +visually until someone edits them. + +Revision ID: 7d07_assettype_color +Revises: 7d06_typecolor_to_hex +Create Date: 2026-07-08 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d07_assettype_color' +down_revision = '7d06_typecolor_to_hex' +branch_labels = None +depends_on = None + + +SEED = { + 'equipment': '#F44336', + 'computer': '#2196F3', + 'printer': '#4CAF50', + 'network_device': '#FF9800', +} + + +def upgrade(): + op.add_column('assettypes', sa.Column('color', sa.String(length=20), nullable=True)) + conn = op.get_bind() + for assettype, hexcolor in SEED.items(): + conn.execute( + sa.text("UPDATE assettypes SET color = :c WHERE assettype = :t AND (color IS NULL OR color = '')"), + {'c': hexcolor, 't': assettype} + ) + + +def downgrade(): + op.drop_column('assettypes', 'color') diff --git a/migrations/versions/7d08_tvslides.py b/migrations/versions/7d08_tvslides.py new file mode 100644 index 00000000..6487aac4 --- /dev/null +++ b/migrations/versions/7d08_tvslides.py @@ -0,0 +1,36 @@ +"""Slide manager: tvslides table + +Backing table for the slides plugin (lobby display + shopfloor screensaver +playlists). Image files live on disk; this holds order + per-slide duration. + +Revision ID: 7d08_tvslides +Revises: 7d07_assettype_color +Create Date: 2026-07-08 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d08_tvslides' +down_revision = '7d07_assettype_color' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'tvslides', + sa.Column('slideid', sa.Integer(), primary_key=True), + sa.Column('surface', sa.String(length=20), nullable=False), + sa.Column('filename', sa.String(length=255), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False, server_default='0'), + sa.Column('seconds', sa.Integer(), nullable=False, server_default='0'), + sa.Column('uploadeddate', sa.DateTime(), nullable=True), + ) + op.create_index('idx_tvslide_surface', 'tvslides', ['surface']) + + +def downgrade(): + op.drop_index('idx_tvslide_surface', table_name='tvslides') + op.drop_table('tvslides') diff --git a/migrations/versions/7d09_subtype_colors.py b/migrations/versions/7d09_subtype_colors.py new file mode 100644 index 00000000..dd15f6a4 --- /dev/null +++ b/migrations/versions/7d09_subtype_colors.py @@ -0,0 +1,31 @@ +"""Add color to asset subtype tables (data-driven map colors) + +The map colors equipment/computer/network/printer subtypes round-robin from a +palette. Give each subtype table a color column so a site can set stable, chosen +colors that the map reads instead of the auto-assigned palette. + +Revision ID: 7d09_subtype_colors +Revises: 7d08_tvslides +Create Date: 2026-07-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d09_subtype_colors' +down_revision = '7d08_tvslides' +branch_labels = None +depends_on = None + +TABLES = ['equipmenttypes', 'computertypes', 'networkdevicetypes', 'printertypes'] + + +def upgrade(): + for table in TABLES: + op.add_column(table, sa.Column('color', sa.String(length=20), nullable=True)) + + +def downgrade(): + for table in TABLES: + op.drop_column(table, 'color') diff --git a/migrations/versions/7d10_relationshiptype_color.py b/migrations/versions/7d10_relationshiptype_color.py new file mode 100644 index 00000000..37508b65 --- /dev/null +++ b/migrations/versions/7d10_relationshiptype_color.py @@ -0,0 +1,26 @@ +"""Add color to relationship types + +Lets sites color relationship types (Controls, Contains, Stored At...) so the +asset relationship graph/badges are visually distinct, like statuses. + +Revision ID: 7d10_relationshiptype_color +Revises: 7d09_subtype_colors +Create Date: 2026-07-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d10_relationshiptype_color' +down_revision = '7d09_subtype_colors' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('relationshiptypes', sa.Column('color', sa.String(length=20), nullable=True)) + + +def downgrade(): + op.drop_column('relationshiptypes', 'color') diff --git a/migrations/versions/7d11_locationtype_color.py b/migrations/versions/7d11_locationtype_color.py new file mode 100644 index 00000000..fc318e21 --- /dev/null +++ b/migrations/versions/7d11_locationtype_color.py @@ -0,0 +1,23 @@ +"""Add color to location types + +Revision ID: 7d11_locationtype_color +Revises: 7d10_relationshiptype_color +Create Date: 2026-07-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d11_locationtype_color' +down_revision = '7d10_relationshiptype_color' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('locationtypes', sa.Column('color', sa.String(length=20), nullable=True)) + + +def downgrade(): + op.drop_column('locationtypes', 'color') diff --git a/migrations/versions/7d12_drop_computer_isshopfloor.py b/migrations/versions/7d12_drop_computer_isshopfloor.py new file mode 100644 index 00000000..5cc07c06 --- /dev/null +++ b/migrations/versions/7d12_drop_computer_isshopfloor.py @@ -0,0 +1,26 @@ +"""Drop computers.isshopfloor (redundant with the Shopfloor PC type) + +The flag was never populated (0 rows) and duplicated the 'Shopfloor' computer +type. Shopfloor classification now lives entirely in computertype. + +Revision ID: 7d12_drop_isshopfloor +Revises: 7d11_locationtype_color +Create Date: 2026-07-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d12_drop_isshopfloor' +down_revision = '7d11_locationtype_color' +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_column('computers', 'isshopfloor') + + +def downgrade(): + op.add_column('computers', sa.Column('isshopfloor', sa.Boolean(), nullable=True, server_default='0')) diff --git a/migrations/versions/7d13_printerdrivers.py b/migrations/versions/7d13_printerdrivers.py new file mode 100644 index 00000000..39078f4d --- /dev/null +++ b/migrations/versions/7d13_printerdrivers.py @@ -0,0 +1,32 @@ +"""Printer drivers: named SMB/HTTP links to driver packages + +Revision ID: 7d13_printerdrivers +Revises: 7d12_drop_isshopfloor +Create Date: 2026-07-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d13_printerdrivers' +down_revision = '7d12_drop_isshopfloor' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'printerdrivers', + sa.Column('driverid', sa.Integer(), primary_key=True), + sa.Column('name', sa.String(length=150), nullable=False), + sa.Column('location', sa.String(length=500), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('modelnumberid', sa.Integer(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'), + sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid']), + ) + + +def downgrade(): + op.drop_table('printerdrivers') diff --git a/migrations/versions/7d14_customfields.py b/migrations/versions/7d14_customfields.py new file mode 100644 index 00000000..3cfe84aa --- /dev/null +++ b/migrations/versions/7d14_customfields.py @@ -0,0 +1,48 @@ +"""Custom fields: site-defined extra attributes per asset type + +Revision ID: 7d14_customfields +Revises: 7d13_printerdrivers +Create Date: 2026-07-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d14_customfields' +down_revision = '7d13_printerdrivers' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'customfields', + sa.Column('fieldid', sa.Integer(), primary_key=True), + sa.Column('assettypeid', sa.Integer(), nullable=False), + sa.Column('fieldkey', sa.String(length=50), nullable=False), + sa.Column('label', sa.String(length=150), nullable=False), + sa.Column('datatype', sa.String(length=20), nullable=False, server_default='text'), + sa.Column('options', sa.Text(), nullable=True), + sa.Column('showondetail', sa.Boolean(), nullable=False, server_default='1'), + sa.Column('showonform', sa.Boolean(), nullable=False, server_default='1'), + sa.Column('sortorder', sa.Integer(), nullable=False, server_default='0'), + sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'), + sa.ForeignKeyConstraint(['assettypeid'], ['assettypes.assettypeid']), + sa.UniqueConstraint('assettypeid', 'fieldkey', name='uq_customfield_type_key'), + ) + op.create_table( + 'customfieldvalues', + sa.Column('valueid', sa.Integer(), primary_key=True), + sa.Column('fieldid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('value', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['fieldid'], ['customfields.fieldid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.UniqueConstraint('fieldid', 'assetid', name='uq_customfieldvalue_field_asset'), + ) + + +def downgrade(): + op.drop_table('customfieldvalues') + op.drop_table('customfields') diff --git a/migrations/versions/7d15_warranties.py b/migrations/versions/7d15_warranties.py new file mode 100644 index 00000000..7cf47d11 --- /dev/null +++ b/migrations/versions/7d15_warranties.py @@ -0,0 +1,45 @@ +"""Warranty plugin: warranties + warrantyassets + +Revision ID: 7d15_warranties +Revises: 7d14_customfields +Create Date: 2026-07-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d15_warranties' +down_revision = '7d14_customfields' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'warranties', + sa.Column('warrantyid', sa.Integer(), primary_key=True), + sa.Column('vendor', sa.String(length=100), nullable=False), + sa.Column('servicetag', sa.String(length=100), nullable=True), + sa.Column('provider', sa.String(length=20), nullable=False, server_default='manual'), + sa.Column('servicelevel', sa.String(length=150), nullable=True), + sa.Column('startdate', sa.Date(), nullable=True), + sa.Column('enddate', sa.Date(), nullable=True), + sa.Column('lastcheckeddate', sa.DateTime(), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'), + ) + op.create_table( + 'warrantyassets', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('warrantyid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['warrantyid'], ['warranties.warrantyid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.UniqueConstraint('warrantyid', 'assetid', name='uq_warrantyasset_warranty_asset'), + ) + + +def downgrade(): + op.drop_table('warrantyassets') + op.drop_table('warranties') diff --git a/migrations/versions/7d16_directoryemployees.py b/migrations/versions/7d16_directoryemployees.py new file mode 100644 index 00000000..aeb2d14c --- /dev/null +++ b/migrations/versions/7d16_directoryemployees.py @@ -0,0 +1,31 @@ +"""Self-hosted employee directory table + +Revision ID: 7d16_directoryemployees +Revises: 7d15_warranties +Create Date: 2026-07-10 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d16_directoryemployees' +down_revision = '7d15_warranties' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'directoryemployees', + sa.Column('sso', sa.Integer(), primary_key=True, autoincrement=False), + sa.Column('firstname', sa.String(length=100), nullable=False), + sa.Column('lastname', sa.String(length=100), nullable=False), + sa.Column('team', sa.String(length=100), nullable=True), + sa.Column('role', sa.String(length=100), nullable=True), + sa.Column('picture', sa.String(length=255), nullable=True), + ) + + +def downgrade(): + op.drop_table('directoryemployees') diff --git a/migrations/versions/7d17_machines_rename.py b/migrations/versions/7d17_machines_rename.py new file mode 100644 index 00000000..2cb4b731 --- /dev/null +++ b/migrations/versions/7d17_machines_rename.py @@ -0,0 +1,205 @@ +"""Equipment -> machines rename, core half + +Part 1 of the approved equipment -> machines rename (the plugin half lives in +plugins/machines/migrations/versions/0002_rename_from_equipment.py): + + 1. machinetypes -> modeltypes (+ machinetypeid -> modeltypeid, + machinetype -> modeltype, models.machinetypeid -> models.modeltypeid). + The table types the vendor MODELS catalog, not machine instances, so + the new name is role-accurate and frees "machinetypes" for the plugin. + 2. Data flips: assettypes 'equipment' -> 'machine' (incl pluginname and + tablename), auditlogs.entitytype 'Equipment' -> 'Machine', settings keys + identifier_*_equipment_enabled -> identifier_*_machine_enabled, + search_equipment_enabled -> search_machine_enabled, and permission + rows equipment.* -> machines.* (role links survive). + 3. alembic_version_equipment -> alembic_version_machines (if present) so + the renamed plugin's migration chain resumes seamlessly. + +Idempotent via inspector guards, following the 7c01 pattern. MySQL is the +only dialect the core chain runs against in practice. + +Revision ID: 7d17_machines_rename +Revises: 7d16_directoryemployees +Create Date: 2026-07-11 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d17_machines_rename' +down_revision = '7d16_directoryemployees' +branch_labels = None +depends_on = None + + +def _fk_names(insp, table, referred_table): + # find FK constraint names on table pointing at referred_table + return [fk['name'] for fk in insp.get_foreign_keys(table) + if fk.get('referred_table') == referred_table and fk.get('name')] + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + tables = set(insp.get_table_names()) + + # -- 1. machinetypes -> modeltypes ------------------------------------ + if 'machinetypes' in tables and 'modeltypes' not in tables: + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0") + + # drop the models -> machinetypes FK and its index before renaming + for name in _fk_names(insp, 'models', 'machinetypes'): + bind.exec_driver_sql(f"ALTER TABLE models DROP FOREIGN KEY {name}") + model_indexes = {ix['name'] for ix in insp.get_indexes('models')} + if 'machinetypeid' in model_indexes: + bind.exec_driver_sql("ALTER TABLE models DROP KEY machinetypeid") + + bind.exec_driver_sql("RENAME TABLE machinetypes TO modeltypes") + bind.exec_driver_sql( + "ALTER TABLE modeltypes " + "CHANGE machinetypeid modeltypeid INT NOT NULL AUTO_INCREMENT, " + "CHANGE machinetype modeltype VARCHAR(100) NOT NULL, " + "DROP KEY machinetype, " + "ADD UNIQUE KEY modeltype (modeltype)" + ) + bind.exec_driver_sql( + "ALTER TABLE models " + "CHANGE machinetypeid modeltypeid INT NULL, " + "ADD KEY modeltypeid (modeltypeid), " + "ADD CONSTRAINT fk_models_modeltypeid " + "FOREIGN KEY (modeltypeid) REFERENCES modeltypes (modeltypeid)" + ) + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1") + + # -- 2. data flips ------------------------------------------------------ + if 'assettypes' in tables: + bind.exec_driver_sql( + "UPDATE assettypes SET assettype='machine', pluginname='machines', " + "tablename='machines' WHERE assettype='equipment'" + ) + if 'auditlogs' in tables: + bind.exec_driver_sql( + "UPDATE auditlogs SET entitytype='Machine' " + "WHERE entitytype='Equipment'" + ) + if 'settings' in tables: + _rename_setting_keys(bind, old_marker='_equipment_', + new_marker='_machine_') + if 'permissions' in tables: + _rename_permissions(bind, old_prefix='equipment.', + new_prefix='machines.', + old_category='equipment', new_category='machines') + + # -- 3. plugin version table -------------------------------------------- + if ('alembic_version_equipment' in tables + and 'alembic_version_machines' not in tables): + bind.exec_driver_sql( + "RENAME TABLE alembic_version_equipment TO alembic_version_machines" + ) + + +def _rename_setting_keys(bind, old_marker, new_marker): + """Rename settings keys embedding the assettype value, collision-safe. + + Covers identifier__equipment_enabled and search_equipment_enabled. + If a row with the new key already exists (fresh seed ran on new code), + the old row is dropped instead of renamed. + """ + # broad fetch; the marker replace below filters non-matches + rows = bind.exec_driver_sql( + "SELECT settingid, `key` FROM settings" + ).fetchall() + for settingid, key in rows: + newkey = key.replace(old_marker, new_marker) + if newkey == key: + continue + exists = bind.exec_driver_sql( + "SELECT 1 FROM settings WHERE `key` = %s", (newkey,) + ).fetchone() + if exists: + bind.exec_driver_sql( + "DELETE FROM settings WHERE settingid = %s", (settingid,)) + else: + bind.exec_driver_sql( + "UPDATE settings SET `key` = %s WHERE settingid = %s", + (newkey, settingid)) + + +def _rename_permissions(bind, old_prefix, new_prefix, old_category, + new_category): + """Rename permission rows in place so role links survive, collision-safe.""" + rows = bind.exec_driver_sql( + "SELECT permissionid, name FROM permissions" + ).fetchall() + for permissionid, name in rows: + if not name.startswith(old_prefix): + continue + newname = new_prefix + name[len(old_prefix):] + exists = bind.exec_driver_sql( + "SELECT 1 FROM permissions WHERE name = %s", (newname,) + ).fetchone() + if exists: + bind.exec_driver_sql( + "DELETE FROM permissions WHERE permissionid = %s", + (permissionid,)) + else: + bind.exec_driver_sql( + "UPDATE permissions SET name = %s, category = %s " + "WHERE permissionid = %s", + (newname, new_category, permissionid)) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + tables = set(insp.get_table_names()) + + if ('alembic_version_machines' in tables + and 'alembic_version_equipment' not in tables): + bind.exec_driver_sql( + "RENAME TABLE alembic_version_machines TO alembic_version_equipment" + ) + + if 'settings' in tables: + _rename_setting_keys(bind, old_marker='_machine_', + new_marker='_equipment_') + if 'permissions' in tables: + _rename_permissions(bind, old_prefix='machines.', + new_prefix='equipment.', + old_category='machines', new_category='equipment') + if 'auditlogs' in tables: + bind.exec_driver_sql( + "UPDATE auditlogs SET entitytype='Equipment' " + "WHERE entitytype='Machine'" + ) + if 'assettypes' in tables: + bind.exec_driver_sql( + "UPDATE assettypes SET assettype='equipment', " + "pluginname='equipment', tablename='equipment' " + "WHERE assettype='machine'" + ) + + if 'modeltypes' in tables and 'machinetypes' not in tables: + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0") + for name in _fk_names(insp, 'models', 'modeltypes'): + bind.exec_driver_sql(f"ALTER TABLE models DROP FOREIGN KEY {name}") + model_indexes = {ix['name'] for ix in insp.get_indexes('models')} + if 'modeltypeid' in model_indexes: + bind.exec_driver_sql("ALTER TABLE models DROP KEY modeltypeid") + bind.exec_driver_sql("RENAME TABLE modeltypes TO machinetypes") + bind.exec_driver_sql( + "ALTER TABLE machinetypes " + "CHANGE modeltypeid machinetypeid INT NOT NULL AUTO_INCREMENT, " + "CHANGE modeltype machinetype VARCHAR(100) NOT NULL, " + "DROP KEY modeltype, " + "ADD UNIQUE KEY machinetype (machinetype)" + ) + bind.exec_driver_sql( + "ALTER TABLE models " + "CHANGE modeltypeid machinetypeid INT NULL, " + "ADD KEY machinetypeid (machinetypeid), " + "ADD CONSTRAINT models_ibfk_1 " + "FOREIGN KEY (machinetypeid) REFERENCES machinetypes (machinetypeid)" + ) + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1") diff --git a/migrations/versions/7d18_supportteamcontacts.py b/migrations/versions/7d18_supportteamcontacts.py new file mode 100644 index 00000000..74fdc314 --- /dev/null +++ b/migrations/versions/7d18_supportteamcontacts.py @@ -0,0 +1,150 @@ +"""Support teams get contacts; drop the legacy appowners pair + +Restructures the application support model: supportteams keeps teamname (now +unique) + teamurl (widened to TEXT for ServiceNow group deep links), gains a +child supportteamcontacts table, and sheds its single-owner appownerid FK and +the appowners table. Each legacy team's appowner is migrated into ONE contact. +applications.supportteamid is unchanged (it already points at supportteams). + +Idempotent guards throughout so it is safe on a partially-migrated box. + +Revision ID: 7d18_supportteamcontacts +Revises: 7d17_machines_rename +Create Date: 2026-07-11 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d18_supportteamcontacts' +down_revision = '7d17_machines_rename' +branch_labels = None +depends_on = None + + +def _has_table(insp, name): + return insp.has_table(name) + + +def _has_column(insp, table, column): + return column in [c['name'] for c in insp.get_columns(table)] + + +def _has_unique(insp, table, name): + return name in [u['name'] for u in insp.get_unique_constraints(table)] + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + # 1. New contacts table. + if not _has_table(insp, 'supportteamcontacts'): + op.create_table( + 'supportteamcontacts', + sa.Column('contactid', sa.Integer(), primary_key=True), + sa.Column('supportteamid', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('sso', sa.String(length=50), nullable=True), + sa.Column('sortorder', sa.Integer(), nullable=False, + server_default='0'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False, + server_default='1'), + sa.ForeignKeyConstraint( + ['supportteamid'], ['supportteams.supportteamid'], + ondelete='CASCADE'), + ) + + # 2. Migrate each team's appowner into one contact. + if _has_table(insp, 'appowners') and \ + _has_column(insp, 'supportteams', 'appownerid'): + bind.exec_driver_sql( + "INSERT INTO supportteamcontacts " + "(supportteamid, name, sso, sortorder, isactive, " + " createddate, modifieddate) " + "SELECT st.supportteamid, ao.appowner, ao.sso, 0, 1, " + " NOW(), NOW() " + "FROM supportteams st " + "JOIN appowners ao ON st.appownerid = ao.appownerid " + "WHERE st.appownerid IS NOT NULL") + + # 3. teamname becomes unique. + if not _has_unique(insp, 'supportteams', 'uq_supportteams_teamname'): + op.create_unique_constraint( + 'uq_supportteams_teamname', 'supportteams', ['teamname']) + + # 4. Widen teamurl to TEXT. + op.alter_column('supportteams', 'teamurl', + existing_type=sa.String(length=255), + type_=sa.Text(), existing_nullable=True) + + # 5. Drop the appownerid FK + column, then the appowners table. + if _has_column(insp, 'supportteams', 'appownerid'): + for fk in insp.get_foreign_keys('supportteams'): + if 'appownerid' in fk['constrained_columns'] and fk.get('name'): + bind.exec_driver_sql( + f"ALTER TABLE supportteams DROP FOREIGN KEY {fk['name']}") + op.drop_column('supportteams', 'appownerid') + bind.exec_driver_sql("DROP TABLE IF EXISTS appowners") + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + # Recreate appowners. + if not _has_table(insp, 'appowners'): + op.create_table( + 'appowners', + sa.Column('appownerid', sa.Integer(), primary_key=True), + sa.Column('appowner', sa.String(length=100), nullable=False), + sa.Column('sso', sa.String(length=50), nullable=True), + sa.Column('email', sa.String(length=100), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False, + server_default='1'), + ) + + # Re-add the appownerid FK column. + if not _has_column(insp, 'supportteams', 'appownerid'): + op.add_column('supportteams', + sa.Column('appownerid', sa.Integer(), nullable=True)) + op.create_foreign_key( + 'fk_supportteams_appownerid', 'supportteams', 'appowners', + ['appownerid'], ['appownerid']) + + # Best-effort data reversal: each team's first active contact -> an owner. + if _has_table(insp, 'supportteamcontacts'): + rows = bind.execute(sa.text( + "SELECT supportteamid, name, sso FROM supportteamcontacts " + "WHERE isactive = 1 ORDER BY supportteamid, sortorder, contactid")) + seen = set() + for row in rows: + if row.supportteamid in seen: + continue + seen.add(row.supportteamid) + result = bind.execute(sa.text( + "INSERT INTO appowners " + "(appowner, sso, isactive, createddate, modifieddate) " + "VALUES (:name, :sso, 1, NOW(), NOW())"), + {'name': row.name, 'sso': row.sso}) + bind.execute(sa.text( + "UPDATE supportteams SET appownerid = :ownerid " + "WHERE supportteamid = :teamid"), + {'ownerid': result.lastrowid, 'teamid': row.supportteamid}) + + # Narrow teamurl back to VARCHAR(255). + op.alter_column('supportteams', 'teamurl', + existing_type=sa.Text(), + type_=sa.String(length=255), existing_nullable=True) + + # Drop the unique constraint. + if _has_unique(insp, 'supportteams', 'uq_supportteams_teamname'): + op.drop_constraint('uq_supportteams_teamname', 'supportteams', + type_='unique') + + op.drop_table('supportteamcontacts') diff --git a/migrations/versions/7d19_relationshiptype_directional.py b/migrations/versions/7d19_relationshiptype_directional.py new file mode 100644 index 00000000..c3bbd783 --- /dev/null +++ b/migrations/versions/7d19_relationshiptype_directional.py @@ -0,0 +1,70 @@ +"""Relationship types get isdirectional; flip symmetric legacy types + +Adds relationshiptypes.isdirectional (default true). The asset relationships +card uses it: directional types keep a source->target reading, symmetric types +(physical links, network connections) collapse both stored direction rows into +one direction-blind "connected" entry per peer. + +Seeds isdirectional=false for the connection-like types present pre-1.0: +Dualpath, connectedto, Cluster Member, Serial Cable, Direct Ethernet, USB, +WiFi. Everything else (controls, Controlled By, Backup For, Master-Slave, +partof, defaultprinter) stays directional. + +Idempotent guards so it is safe on a partially-migrated box. + +Revision ID: 7d19_relationshiptype_directional +Revises: 7d18_supportteamcontacts +Create Date: 2026-07-11 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d19_relationshiptype_directional' +down_revision = '7d18_supportteamcontacts' +branch_labels = None +depends_on = None + + +# names seeded symmetric (isdirectional=false) +SYMMETRIC_TYPES = [ + 'Dualpath', + 'connectedto', + 'Cluster Member', + 'Serial Cable', + 'Direct Ethernet', + 'USB', + 'WiFi', +] + + +def _has_column(insp, table, column): + return column in [c['name'] for c in insp.get_columns(table)] + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if not _has_column(insp, 'relationshiptypes', 'isdirectional'): + op.add_column( + 'relationshiptypes', + sa.Column('isdirectional', sa.Boolean(), nullable=False, + server_default='1')) + + # flip the known symmetric types. re-running just re-sets the same values. + placeholders = ', '.join([f':n{i}' for i in range(len(SYMMETRIC_TYPES))]) + params = {f'n{i}': name for i, name in enumerate(SYMMETRIC_TYPES)} + bind.execute( + sa.text( + "UPDATE relationshiptypes SET isdirectional = 0 " + f"WHERE relationshiptype IN ({placeholders})"), + params) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if _has_column(insp, 'relationshiptypes', 'isdirectional'): + op.drop_column('relationshiptypes', 'isdirectional') diff --git a/migrations/versions/7d20_relationshiptypepropagations.py b/migrations/versions/7d20_relationshiptypepropagations.py new file mode 100644 index 00000000..c22498b9 --- /dev/null +++ b/migrations/versions/7d20_relationshiptypepropagations.py @@ -0,0 +1,109 @@ +"""Relationship-type propagation goes M:N (relationshiptypepropagations) + +Replaces the single-valued relationshiptypes.propagatesthroughid with a join +table so one type can propagate through several through-types at once. On this +DB `controls` needs both `partof` (declared) and `Dualpath` (consumed: a +dual-bay pair is one physical machine with a single controller, so both bays +must carry the controls link to that PC). + +Upgrade: create relationshiptypepropagations, migrate the existing +propagatesthroughid value into a row, drop the column, then seed the +controls -> Dualpath row (resolved by name, skipped if either type missing). +Downgrade: recreate the column from the first propagation row per type (best +effort) and drop the table. + +Idempotent guards so it is safe on a partially-migrated box. + +Revision ID: 7d20_relationshiptypepropagations +Revises: 7d19_relationshiptype_directional +Create Date: 2026-07-11 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d20_relationshiptypepropagations' +down_revision = '7d19_relationshiptype_directional' +branch_labels = None +depends_on = None + + +def _has_column(insp, table, column): + return column in [c['name'] for c in insp.get_columns(table)] + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'relationshiptypepropagations' not in insp.get_table_names(): + op.create_table( + 'relationshiptypepropagations', + sa.Column('propagationid', sa.Integer(), primary_key=True), + sa.Column('relationshiptypeid', sa.Integer(), nullable=False), + sa.Column('throughtypeid', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['relationshiptypeid'], + ['relationshiptypes.relationshiptypeid']), + sa.ForeignKeyConstraint(['throughtypeid'], + ['relationshiptypes.relationshiptypeid']), + sa.UniqueConstraint('relationshiptypeid', 'throughtypeid', + name='uq_reltype_propagation'), + ) + + if _has_column(insp, 'relationshiptypes', 'propagatesthroughid'): + # migrate the single-FK value into a row (dedup guard for re-runs) + bind.execute(sa.text( + "INSERT INTO relationshiptypepropagations " + "(relationshiptypeid, throughtypeid) " + "SELECT rt.relationshiptypeid, rt.propagatesthroughid " + "FROM relationshiptypes rt " + "WHERE rt.propagatesthroughid IS NOT NULL AND NOT EXISTS (" + " SELECT 1 FROM relationshiptypepropagations p " + " WHERE p.relationshiptypeid = rt.relationshiptypeid " + " AND p.throughtypeid = rt.propagatesthroughid)")) + # drop the self-FK (known name from 7a01, else reflect) then the column + fk_names = [fk['name'] for fk in insp.get_foreign_keys('relationshiptypes') + if fk.get('constrained_columns') == ['propagatesthroughid'] + and fk.get('name')] + for name in fk_names: + op.drop_constraint(name, 'relationshiptypes', type_='foreignkey') + op.drop_column('relationshiptypes', 'propagatesthroughid') + + # seed controls -> Dualpath by name; skip if either type is missing + rows = bind.execute(sa.text( + "SELECT relationshiptypeid, relationshiptype FROM relationshiptypes")).fetchall() + byname = {r[1]: r[0] for r in rows} + controls = byname.get('controls') + dualpath = byname.get('Dualpath') + if controls and dualpath: + exists = bind.execute(sa.text( + "SELECT 1 FROM relationshiptypepropagations " + "WHERE relationshiptypeid = :c AND throughtypeid = :d"), + {'c': controls, 'd': dualpath}).fetchone() + if not exists: + bind.execute(sa.text( + "INSERT INTO relationshiptypepropagations " + "(relationshiptypeid, throughtypeid) VALUES (:c, :d)"), + {'c': controls, 'd': dualpath}) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if not _has_column(insp, 'relationshiptypes', 'propagatesthroughid'): + op.add_column('relationshiptypes', + sa.Column('propagatesthroughid', sa.Integer(), nullable=True)) + op.create_foreign_key('fk_relationshiptype_propagation', + 'relationshiptypes', 'relationshiptypes', + ['propagatesthroughid'], ['relationshiptypeid']) + + if 'relationshiptypepropagations' in insp.get_table_names(): + # best effort: restore the first propagation row per type + bind.execute(sa.text( + "UPDATE relationshiptypes rt SET propagatesthroughid = (" + " SELECT p.throughtypeid FROM relationshiptypepropagations p " + " WHERE p.relationshiptypeid = rt.relationshiptypeid " + " ORDER BY p.propagationid LIMIT 1)")) + op.drop_table('relationshiptypepropagations') diff --git a/migrations/versions/7d21_apitokens.py b/migrations/versions/7d21_apitokens.py new file mode 100644 index 00000000..72e373ff --- /dev/null +++ b/migrations/versions/7d21_apitokens.py @@ -0,0 +1,61 @@ +"""Personal API tokens (apitokens) + +Adds the apitokens table: personal access tokens that let scripts and +integrations authenticate as a user without the hourly-expiring login JWT. +Only the sha256 hash of each secret is stored. + +Idempotent guard so it is safe on a partially-migrated box; real downgrade. + +Revision ID: 7d21_apitokens +Revises: 7d20_relationshiptypepropagations +Create Date: 2026-07-12 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d21_apitokens' +down_revision = '7d20_relationshiptypepropagations' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'apitokens' in insp.get_table_names(): + return + + op.create_table( + 'apitokens', + sa.Column('tokenid', sa.Integer(), primary_key=True), + sa.Column('userid', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('tokenprefix', sa.String(length=16), nullable=True), + sa.Column('tokenhash', sa.String(length=64), nullable=False), + sa.Column('expiresat', sa.DateTime(), nullable=True), + sa.Column('lastusedat', sa.DateTime(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'), + sa.ForeignKeyConstraint(['userid'], ['users.userid']), + sa.UniqueConstraint('tokenhash', name='uq_apitoken_tokenhash'), + ) + op.create_index('ix_apitokens_userid', 'apitokens', ['userid']) + op.create_index('ix_apitokens_tokenprefix', 'apitokens', ['tokenprefix']) + op.create_index('ix_apitokens_tokenhash', 'apitokens', ['tokenhash']) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'apitokens' not in insp.get_table_names(): + return + + op.drop_index('ix_apitokens_tokenhash', table_name='apitokens') + op.drop_index('ix_apitokens_tokenprefix', table_name='apitokens') + op.drop_index('ix_apitokens_userid', table_name='apitokens') + op.drop_table('apitokens') diff --git a/migrations/versions/7d22_apitokens_scopes.py b/migrations/versions/7d22_apitokens_scopes.py new file mode 100644 index 00000000..d12ecd11 --- /dev/null +++ b/migrations/versions/7d22_apitokens_scopes.py @@ -0,0 +1,49 @@ +"""API token permission scopes (apitokens.scopes) + +Adds a nullable scopes column to apitokens: a JSON array of permission-name +strings. NULL means the token is unscoped and acts with the full authority of +its owner (the original behavior). A scoped token grants ONLY the listed +permissions, intersected with what the owner actually holds, and suspends the +admin-role bypass so a scoped token minted by an admin is genuinely limited. + +Idempotent guard so it is safe on a partially-migrated box; real downgrade. + +Revision ID: 7d22_apitokens_scopes +Revises: 7d21_apitokens +Create Date: 2026-07-12 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d22_apitokens_scopes' +down_revision = '7d21_apitokens' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'apitokens' not in insp.get_table_names(): + return + columns = {c['name'] for c in insp.get_columns('apitokens')} + if 'scopes' in columns: + return + + op.add_column('apitokens', sa.Column('scopes', sa.Text(), nullable=True)) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'apitokens' not in insp.get_table_names(): + return + columns = {c['name'] for c in insp.get_columns('apitokens')} + if 'scopes' not in columns: + return + + op.drop_column('apitokens', 'scopes') diff --git a/migrations/versions/7d23_user_mustchangepassword.py b/migrations/versions/7d23_user_mustchangepassword.py new file mode 100644 index 00000000..de7eeebc --- /dev/null +++ b/migrations/versions/7d23_user_mustchangepassword.py @@ -0,0 +1,50 @@ +"""User forced-password-change flag (users.mustchangepassword) + +Adds a boolean users.mustchangepassword column (default false). An admin who +creates a user sets it true so the user is forced through a password change on +first login; changing the password clears it. + +Idempotent guard so it is safe on a partially-migrated box; real downgrade. + +Revision ID: 7d23_user_mustchangepassword +Revises: 7d22_apitokens_scopes +Create Date: 2026-07-12 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d23_user_mustchangepassword' +down_revision = '7d22_apitokens_scopes' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'users' not in insp.get_table_names(): + return + columns = {c['name'] for c in insp.get_columns('users')} + if 'mustchangepassword' in columns: + return + + op.add_column( + 'users', + sa.Column('mustchangepassword', sa.Boolean(), + nullable=False, server_default=sa.false())) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'users' not in insp.get_table_names(): + return + columns = {c['name'] for c in insp.get_columns('users')} + if 'mustchangepassword' not in columns: + return + + op.drop_column('users', 'mustchangepassword') diff --git a/migrations/versions/7d24_customfield_searchable.py b/migrations/versions/7d24_customfield_searchable.py new file mode 100644 index 00000000..bb0b3fef --- /dev/null +++ b/migrations/versions/7d24_customfield_searchable.py @@ -0,0 +1,50 @@ +"""Custom field searchable flag (customfields.searchable) + +Adds a boolean customfields.searchable column (default false). When true, the +field's stored values are matched by global search and a hit routes to the +owning asset's detail page. + +Idempotent guard so it is safe on a partially-migrated box; real downgrade. + +Revision ID: 7d24_customfield_searchable +Revises: 7d23_user_mustchangepassword +Create Date: 2026-07-12 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d24_customfield_searchable' +down_revision = '7d23_user_mustchangepassword' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'customfields' not in insp.get_table_names(): + return + columns = {c['name'] for c in insp.get_columns('customfields')} + if 'searchable' in columns: + return + + op.add_column( + 'customfields', + sa.Column('searchable', sa.Boolean(), + nullable=False, server_default=sa.false())) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'customfields' not in insp.get_table_names(): + return + columns = {c['name'] for c in insp.get_columns('customfields')} + if 'searchable' not in columns: + return + + op.drop_column('customfields', 'searchable') diff --git a/migrations/versions/7d25_drop_redundant_indexes.py b/migrations/versions/7d25_drop_redundant_indexes.py new file mode 100644 index 00000000..a4c49a86 --- /dev/null +++ b/migrations/versions/7d25_drop_redundant_indexes.py @@ -0,0 +1,57 @@ +"""Drop redundant secondary indexes duplicating a named idx_* / unique index. + +The DB review found five non-unique secondary indexes that duplicate an existing +index on the same column: the auto-named ix_* from a column index=True alongside +a named idx_* in __table_args__ (communications.assetid, and the hostname on +computers/networkdevices/printers), plus idx_usb_serial duplicating the +serialnumber unique index on usbdevices. Each redundant pair costs insert/update +maintenance with zero read benefit. The models had the redundant index source +removed; this drops the live duplicates so the schema matches the models. + +Idempotent (skips a missing table/index); real downgrade recreates them. + +Revision ID: 7d25_drop_redundant_indexes +Revises: 7d24_customfield_searchable +Create Date: 2026-07-13 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d25_drop_redundant_indexes' +down_revision = '7d24_customfield_searchable' +branch_labels = None +depends_on = None + +# (table, index name, columns, unique) - columns/unique used only to recreate on +# downgrade. All non-unique; the surviving idx_* / unique index still covers each. +_REDUNDANT = [ + ('communications', 'ix_communications_assetid', ['assetid'], False), + ('computers', 'ix_computers_hostname', ['hostname'], False), + ('networkdevices', 'ix_networkdevices_hostname', ['hostname'], False), + ('printers', 'ix_printers_hostname', ['hostname'], False), + ('usbdevices', 'idx_usb_serial', ['serialnumber'], False), +] + + +def _index_names(insp, table): + return {i['name'] for i in insp.get_indexes(table)} + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + tables = set(insp.get_table_names()) + for table, name, _cols, _unique in _REDUNDANT: + if table in tables and name in _index_names(insp, table): + op.drop_index(name, table_name=table) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + tables = set(insp.get_table_names()) + for table, name, cols, unique in _REDUNDANT: + if table in tables and name not in _index_names(insp, table): + op.create_index(name, table, cols, unique=unique) diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 00000000..0c42fb27 --- /dev/null +++ b/plugins/__init__.py @@ -0,0 +1 @@ +"""ShopDB plugins package.""" diff --git a/plugins/computers/__init__.py b/plugins/computers/__init__.py new file mode 100644 index 00000000..c96a5374 --- /dev/null +++ b/plugins/computers/__init__.py @@ -0,0 +1,5 @@ +"""Computers plugin for ShopDB.""" + +from .plugin import ComputersPlugin + +__all__ = ['ComputersPlugin'] diff --git a/plugins/computers/api/__init__.py b/plugins/computers/api/__init__.py new file mode 100644 index 00000000..0bea25bc --- /dev/null +++ b/plugins/computers/api/__init__.py @@ -0,0 +1,5 @@ +"""Computers plugin API.""" + +from .routes import computers_bp + +__all__ = ['computers_bp'] diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py new file mode 100644 index 00000000..e028e24e --- /dev/null +++ b/plugins/computers/api/routes.py @@ -0,0 +1,894 @@ +"""Computers plugin API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query + +from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess + +from shopdb.api import require_permission, apply_import_timestamps + +computers_bp = Blueprint('computers', __name__) + + +# ============================================================================= +# Computer Types +# ============================================================================= + +@computers_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_computer_types(): + """List all computer types.""" + page, per_page = get_pagination_params(request) + + query = ComputerType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(ComputerType.isactive == True) + + if search := request.args.get('search'): + query = query.filter(ComputerType.computertype.ilike(f'%{search}%')) + + query = query.order_by(ComputerType.computertype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@computers_bp.route('/types/', methods=['GET']) +@jwt_required(optional=True) +def get_computer_type(type_id: int): + """Get a single computer type.""" + t = db.session.get(ComputerType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@computers_bp.route('/types', methods=['POST']) +@jwt_required() +@require_permission('computers.create') +def create_computer_type(): + """Create a new computer type.""" + data = request.get_json() + + if not data or not data.get('computertype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'computertype is required') + + existing = ComputerType.query.filter_by(computertype=data['computertype']).first() + if existing: + if not existing.isactive: + # Adding a name that matches a deactivated type revives it. + existing.isactive = True + for key in ('description', 'icon', 'color'): + if data.get(key) is not None: + setattr(existing, key, data[key]) + db.session.commit() + return success_response(existing.to_dict(), message='Reactivated existing computer type') + return error_response( + ErrorCodes.CONFLICT, + f"Computer type '{data['computertype']}' already exists", + http_code=409 + ) + + t = ComputerType( + computertype=data['computertype'], + description=data.get('description'), + icon=data.get('icon'), color=data.get('color') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Computer type created', http_code=201) + + +@computers_bp.route('/types/', methods=['PUT']) +@jwt_required() +@require_permission('computers.edit') +def update_computer_type(type_id: int): + """Update a computer type.""" + t = db.session.get(ComputerType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer type with ID {type_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'computertype' in data and data['computertype'] != t.computertype: + if ComputerType.query.filter_by(computertype=data['computertype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Computer type '{data['computertype']}' already exists", + http_code=409 + ) + + for key in ['computertype', 'description', 'icon', 'color', 'isactive']: + if key in data: + setattr(t, key, data[key]) + + db.session.commit() + return success_response(t.to_dict(), message='Computer type updated') + + +@computers_bp.route('/types/', methods=['DELETE']) +@jwt_required() +@require_permission('computers.delete') +def delete_computer_type(type_id: int): + """Delete a computer type. Refused if any PC still uses it.""" + t = db.session.get(ComputerType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Computer type not found', http_code=404) + inuse = Computer.query.filter_by(computertypeid=type_id).count() + if inuse: + return error_response(ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} PC(s) still use this type", http_code=409) + db.session.delete(t) + db.session.commit() + return success_response(message='Computer type deleted') + + +# ============================================================================= +# Access protocol catalog (VNC / WinRM / RDP / ...) - admin-managed +# ============================================================================= + +@computers_bp.route('/protocols', methods=['GET']) +@jwt_required(optional=True) +def list_protocols(): + """List access protocols. ?active=false includes disabled ones.""" + query = AccessProtocol.query + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(AccessProtocol.isactive == True) + protocols = query.order_by(AccessProtocol.name).all() + return success_response([p.to_dict() for p in protocols]) + + +@computers_bp.route('/protocols', methods=['POST']) +@jwt_required() +@require_permission('computers.edit') +def create_protocol(): + data = request.get_json() or {} + if not (data.get('name') and data.get('scheme') and data.get('linktemplate')): + return error_response(ErrorCodes.VALIDATION_ERROR, 'name, scheme and linktemplate are required') + if AccessProtocol.query.filter_by(name=data['name']).first(): + return error_response(ErrorCodes.CONFLICT, f"Protocol '{data['name']}' already exists", http_code=409) + p = AccessProtocol( + name=data['name'], + scheme=data['scheme'], + defaultport=data.get('defaultport') or None, + linktemplate=data['linktemplate'], + isactive=data.get('isactive', True), + ) + db.session.add(p) + db.session.commit() + return success_response(p.to_dict(), message='Protocol created', http_code=201) + + +@computers_bp.route('/protocols/', methods=['PUT', 'PATCH']) +@jwt_required() +@require_permission('computers.edit') +def update_protocol(protocol_id): + p = db.session.get(AccessProtocol, protocol_id) + if not p: + return error_response(ErrorCodes.NOT_FOUND, 'Protocol not found', http_code=404) + data = request.get_json() or {} + for field in ('name', 'scheme', 'linktemplate'): + if data.get(field): + setattr(p, field, data[field]) + if 'defaultport' in data: + p.defaultport = data['defaultport'] or None + if 'isactive' in data: + p.isactive = bool(data['isactive']) + db.session.commit() + return success_response(p.to_dict(), message='Protocol updated') + + +@computers_bp.route('/protocols/', methods=['DELETE']) +@jwt_required() +@require_permission('computers.edit') +def delete_protocol(protocol_id): + p = db.session.get(AccessProtocol, protocol_id) + if not p: + return error_response(ErrorCodes.NOT_FOUND, 'Protocol not found', http_code=404) + # If any PC still references it, deactivate rather than hard-delete. + if ComputerAccess.query.filter_by(protocolid=protocol_id).first(): + p.isactive = False + db.session.commit() + return success_response(message='Protocol is in use; deactivated instead of deleted') + db.session.delete(p) + db.session.commit() + return success_response(message='Protocol deleted') + + +def _pc_access_domain(): + # Contract-pure read of the pc_access_domain setting (no core.api import). + row = Setting.query.filter_by(key='pc_access_domain').first() + return ((row.value if row else '') or '').strip() + + +def _computer_access_links(comp, domain=None): + """Resolved remote-access links for a computer: each enabled protocol's + template filled with the PC hostname joined to the pc_access_domain setting. + A hostname that is already an FQDN (has a dot) is used as-is. Pass domain + when calling in a loop to avoid one settings lookup per computer.""" + if domain is None: + domain = _pc_access_domain() + hostname = (comp.hostname or '').strip() + if not hostname: + host = '' + elif '.' in hostname or not domain: + host = hostname + else: + host = f"{hostname}.{domain}" + + links = [] + for am in comp.accessmethods: + protocol = am.protocol + if not (am.isactive and protocol and protocol.isactive): + continue + port = am.portoverride or protocol.defaultport + link = None + if host: + try: + link = protocol.linktemplate.format( + host=host, + port=(port if port is not None else ''), + scheme=protocol.scheme, + ) + except (KeyError, IndexError, ValueError): + link = None + links.append({ + 'id': am.id, + 'protocolid': protocol.protocolid, + 'name': protocol.name, + 'scheme': protocol.scheme, + 'port': port, + 'portoverride': am.portoverride, + 'link': link, + }) + return links + + +def _sync_access_methods(comp, data): + """Replace a computer's enabled protocols from data['accessmethods'] (a list + of {protocolid, portoverride?}). No-op if the key is absent, so callers that + don't touch access aren't affected.""" + if 'accessmethods' not in data: + return + desired = data.get('accessmethods') or [] + ComputerAccess.query.filter_by(computerid=comp.computerid).delete() + seen = set() + for m in desired: + try: + pid = int(m.get('protocolid')) + except (TypeError, ValueError): + continue + if pid in seen: + continue + seen.add(pid) + port = m.get('portoverride') + try: + port = int(port) if port not in (None, '') else None + except (TypeError, ValueError): + port = None + db.session.add(ComputerAccess( + computerid=comp.computerid, + protocolid=pid, + portoverride=port, + isactive=True, + )) + + +# ============================================================================= +# Computers CRUD +# ============================================================================= + +@computers_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_computers(): + """ + List all computers with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status + - search: Search by asset number, name, or hostname + - type_id: Filter by computer type ID + - os_id: Filter by operating system ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + - shopfloor: Filter by shopfloor flag (true/false) + """ + page, per_page = get_pagination_params(request) + + # Join Computer with Asset + query = db.session.query(Computer).join(Asset) + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Exact-match natural-key lookup for idempotent import (asset number). + if exactassetnumber := request.args.get('assetnumber'): + query = query.filter(Asset.assetnumber == exactassetnumber) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%'), + Computer.hostname.ilike(f'%{search}%') + ) + ) + + # Computer type filter + if type_id := request.args.get('typeid', request.args.get('type_id')): + query = query.filter(Computer.computertypeid == int(type_id)) + + # OS filter + if os_id := request.args.get('osid', request.args.get('os_id')): + query = query.filter(Computer.osid == int(os_id)) + + # Location filter + if location_id := request.args.get('locationid', request.args.get('location_id')): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Shopfloor filter (by the Shopfloor computer type) + if shopfloor := request.args.get('shopfloor'): + sf = ComputerType.query.filter_by(computertype='Shopfloor').first() + sf_id = sf.computertypeid if sf else -1 + if shopfloor.lower() == 'true': + query = query.filter(Computer.computertypeid == sf_id) + else: + query = query.filter(db.or_(Computer.computertypeid != sf_id, + Computer.computertypeid.is_(None))) + + # Sorting + sort_by = request.args.get('sort', 'hostname') + sort_dir = request.args.get('dir', 'asc') + + if sort_by == 'hostname': + col = Computer.hostname + elif sort_by == 'assetnumber': + col = Asset.assetnumber + elif sort_by == 'name': + col = Asset.name + elif sort_by == 'lastreporteddate': + col = Computer.lastreporteddate + else: + col = Computer.hostname + + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + + items, total = paginate_query(query, page, per_page) + + # Build response with both asset and computer data + data = [] + accessdomain = _pc_access_domain() + for comp in items: + item = comp.asset.to_dict() if comp.asset else {} + item['computer'] = comp.to_dict() + item['accessmethods'] = _computer_access_links(comp, domain=accessdomain) + data.append(item) + + return paginated_response(data, page, per_page, total) + + +@computers_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_computer(computer_id: int): + """Get a single computer with full details.""" + comp = db.session.get(Computer, computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + result = comp.asset.to_dict() if comp.asset else {} + result['computer'] = comp.to_dict() + result['communications'] = [ + c.to_dict() for c in + Communication.query.filter_by(assetid=comp.assetid).all() + ] + result['accessmethods'] = _computer_access_links(comp) + + return success_response(result) + + +@computers_bp.route('/by-asset/', methods=['GET']) +@jwt_required(optional=True) +def get_computer_by_asset(asset_id: int): + """Get computer data by asset ID.""" + comp = Computer.query.filter_by(assetid=asset_id).first() + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer for asset {asset_id} not found', + http_code=404 + ) + + result = comp.asset.to_dict() if comp.asset else {} + result['computer'] = comp.to_dict() + + return success_response(result) + + +@computers_bp.route('/by-hostname/', methods=['GET']) +@jwt_required(optional=True) +def get_computer_by_hostname(hostname: str): + """Get computer by hostname.""" + comp = Computer.query.filter_by(hostname=hostname).first() + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with hostname {hostname} not found', + http_code=404 + ) + + result = comp.asset.to_dict() if comp.asset else {} + result['computer'] = comp.to_dict() + + return success_response(result) + + +@computers_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('computers.create') +def create_computer(): + """ + Create new computer (creates both Asset and Computer records). + + Required fields: + - assetnumber: Business identifier + + Optional fields: + - name, serialnumber, statusid, locationid, businessunitid + - computertypeid, hostname, osid + - mapx, mapy, notes + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Check for duplicate hostname + if data.get('hostname'): + if Computer.query.filter_by(hostname=data['hostname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Computer with hostname '{data['hostname']}' already exists", + http_code=409 + ) + + # Get computer asset type + computer_type = AssetType.query.filter_by(assettype='computer').first() + if not computer_type: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Computer asset type not found. Plugin may not be properly installed.', + http_code=500 + ) + + # Create the core asset + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + serialnumber=data.get('serialnumber'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), + assettypeid=computer_type.assettypeid, + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapx=data.get('mapx'), + mapy=data.get('mapy'), + notes=data.get('notes') + ) + + db.session.add(asset) + db.session.flush() # Get the assetid + + # Create the computer extension + comp = Computer( + assetid=asset.assetid, + computertypeid=data.get('computertypeid'), + hostname=data.get('hostname'), + osid=data.get('osid'), + vendorid=data.get('vendorid'), + modelnumberid=data.get('modelnumberid'), + loggedinuser=data.get('loggedinuser'), + lastreporteddate=data.get('lastreporteddate'), + lastboottime=data.get('lastboottime') + ) + + db.session.add(comp) + db.session.flush() + + # Optional primary IP communication + if data.get('ipaddress'): + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + if ip_comtype: + db.session.add(Communication( + assetid=asset.assetid, + comtypeid=ip_comtype.comtypeid, + ipaddress=data['ipaddress'], + isprimary=True, + )) + + # Remote-access protocols + _sync_access_methods(comp, data) + + # Preserve legacy timestamps in import mode (no-op otherwise) + apply_import_timestamps(asset, data) + + # Audit log + AuditLog.log('created', 'Computer', entityid=comp.computerid, + entityname=data.get('hostname') or data['assetnumber']) + + db.session.commit() + + result = asset.to_dict() + result['computer'] = comp.to_dict() + result['accessmethods'] = _computer_access_links(comp) + + return success_response(result, message='Computer created', http_code=201) + + +@computers_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('computers.edit') +def update_computer(computer_id: int): + """Update computer (both Asset and Computer records).""" + comp = db.session.get(Computer, computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + asset = comp.asset + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Check for conflicting hostname + if 'hostname' in data and data['hostname'] != comp.hostname: + existing = Computer.query.filter_by(hostname=data['hostname']).first() + if existing and existing.computerid != computer_id: + return error_response( + ErrorCodes.CONFLICT, + f"Computer with hostname '{data['hostname']}' already exists", + http_code=409 + ) + + # Track changes for audit log + changes = {} + + # Update asset fields + asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference', + 'maintenancereference', 'statusid', + 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] + for key in asset_fields: + if key in data: + old_val = getattr(asset, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(asset, key, data[key]) + + # Update computer fields + computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid', + 'modelnumberid', 'loggedinuser', 'lastreporteddate', + 'lastboottime'] + for key in computer_fields: + if key in data: + old_val = getattr(comp, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(comp, key, data[key]) + + # Upsert the primary IP communication so a single PUT covers it + if 'ipaddress' in data: + ip = (data.get('ipaddress') or '').strip() + primary = Communication.query.filter_by( + assetid=asset.assetid, isprimary=True).first() + if ip: + if primary: + primary.ipaddress = ip + else: + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + if ip_comtype: + db.session.add(Communication( + assetid=asset.assetid, comtypeid=ip_comtype.comtypeid, + ipaddress=ip, isprimary=True)) + elif primary: + primary.ipaddress = None + + # Remote-access protocols + _sync_access_methods(comp, data) + + # Audit log if there were changes + if changes: + AuditLog.log('updated', 'Computer', entityid=comp.computerid, + entityname=comp.hostname or asset.assetnumber, changes=changes) + + apply_import_timestamps(asset, data) + db.session.commit() + + result = asset.to_dict() + result['computer'] = comp.to_dict() + result['accessmethods'] = _computer_access_links(comp) + + return success_response(result, message='Computer updated') + + +@computers_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('computers.delete') +def delete_computer(computer_id: int): + """Delete (soft delete) computer.""" + comp = db.session.get(Computer, computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + # Soft delete the asset + comp.asset.isactive = False + + # Audit log + AuditLog.log('deleted', 'Computer', entityid=comp.computerid, + entityname=comp.hostname or comp.asset.assetnumber) + + db.session.commit() + + return success_response(message='Computer deleted') + + +# ============================================================================= +# Installed Applications +# ============================================================================= + +@computers_bp.route('//apps', methods=['GET']) +@jwt_required(optional=True) +def get_installed_apps(computer_id: int): + """Get all installed applications for a computer.""" + comp = db.session.get(Computer, computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + apps = ComputerInstalledApp.query.filter_by( + computerid=computer_id, + isactive=True + ).all() + + data = [app.to_dict() for app in apps] + + return success_response(data) + + +@computers_bp.route('//apps', methods=['POST']) +@jwt_required() +@require_permission('computers.create') +def add_installed_app(computer_id: int): + """Add an installed application to a computer.""" + comp = db.session.get(Computer, computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data or not data.get('appid'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'appid is required') + + appid = data['appid'] + + # Validate app exists + if not db.session.get(Application, appid): + return error_response(ErrorCodes.NOT_FOUND, f'Application {appid} not found', http_code=404) + + # Check for duplicate + existing = ComputerInstalledApp.query.filter_by( + computerid=computer_id, + appid=appid + ).first() + + if existing: + if existing.isactive: + return error_response( + ErrorCodes.CONFLICT, + 'This application is already installed on this computer', + http_code=409 + ) + else: + # Reactivate + existing.isactive = True + existing.appversionid = data.get('appversionid') + db.session.commit() + return success_response(existing.to_dict(), message='Application reinstalled') + + # Create new installation record + installed = ComputerInstalledApp( + computerid=computer_id, + appid=appid, + appversionid=data.get('appversionid') + ) + + db.session.add(installed) + db.session.commit() + + return success_response(installed.to_dict(), message='Application installed', http_code=201) + + +@computers_bp.route('//apps/', methods=['DELETE']) +@jwt_required() +@require_permission('computers.delete') +def remove_installed_app(computer_id: int, app_id: int): + """Remove an installed application from a computer.""" + installed = ComputerInstalledApp.query.filter_by( + computerid=computer_id, + appid=app_id, + isactive=True + ).first() + + if not installed: + return error_response( + ErrorCodes.NOT_FOUND, + 'Installation record not found', + http_code=404 + ) + + installed.isactive = False + db.session.commit() + + return success_response(message='Application uninstalled') + + +# ============================================================================= +# Status Reporting +# ============================================================================= + +@computers_bp.route('//report', methods=['POST']) +@jwt_required() +@require_permission('computers.create') +def report_status(computer_id: int): + """ + Report computer status (for agent-based reporting). + + This endpoint can be called periodically by a client agent + to update status information. + """ + comp = db.session.get(Computer, computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + data = request.get_json() or {} + + # Update status fields + from datetime import datetime, timezone + comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None) + + if 'loggedinuser' in data: + comp.loggedinuser = data['loggedinuser'] + if 'lastboottime' in data: + comp.lastboottime = data['lastboottime'] + + db.session.commit() + + return success_response(message='Status reported') + + +# ============================================================================= +# Dashboard +# ============================================================================= + +@computers_bp.route('/dashboard/summary', methods=['GET']) +@jwt_required(optional=True) +def dashboard_summary(): + """Get computer dashboard summary data.""" + # Total active computers + total = db.session.query(Computer).join(Asset).filter( + Asset.isactive == True + ).count() + + # Count by computer type + by_type = db.session.query( + ComputerType.computertype, + db.func.count(Computer.computerid) + ).join(Computer, Computer.computertypeid == ComputerType.computertypeid + ).join(Asset, Asset.assetid == Computer.assetid + ).filter(Asset.isactive == True + ).group_by(ComputerType.computertype + ).all() + + # Count by OS + by_os = db.session.query( + OperatingSystem.osname, + db.func.count(Computer.computerid) + ).join(Computer, Computer.osid == OperatingSystem.osid + ).join(Asset, Asset.assetid == Computer.assetid + ).filter(Asset.isactive == True + ).group_by(OperatingSystem.osname + ).all() + + # Count shopfloor vs non-shopfloor + sf = ComputerType.query.filter_by(computertype='Shopfloor').first() + shopfloor_count = db.session.query(Computer).join(Asset).filter( + Asset.isactive == True, + Computer.computertypeid == (sf.computertypeid if sf else -1) + ).count() + + return success_response({ + 'total': total, + 'bytype': [{'type': t, 'count': c} for t, c in by_type], + 'byos': [{'os': o, 'count': c} for o, c in by_os], + 'shopfloor': shopfloor_count, + 'nonshopfloor': total - shopfloor_count + }) diff --git a/plugins/computers/manifest.json b/plugins/computers/manifest.json new file mode 100644 index 00000000..c0ce3513 --- /dev/null +++ b/plugins/computers/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "computers", + "version": "1.0.0", + "description": "Computer management plugin for PCs, servers, and workstations with software tracking", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/computers", + "provides": { + "asset_type": "computer", + "features": [ + "computer_tracking", + "software_inventory", + "remote_access", + "os_management" + ] + }, + "settings": { + "enable_winrm": true, + "enable_vnc": true, + "auto_report_interval_hours": 24 + } +} diff --git a/plugins/computers/migrations/env.py b/plugins/computers/migrations/env.py new file mode 100644 index 00000000..03290a11 --- /dev/null +++ b/plugins/computers/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the computers 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_computers. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'computers' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/computers/migrations/script.py.mako b/plugins/computers/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/computers/migrations/script.py.mako @@ -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"} diff --git a/plugins/computers/migrations/versions/0001_computers_anchor.py b/plugins/computers/migrations/versions/0001_computers_anchor.py new file mode 100644 index 00000000..9ad50307 --- /dev/null +++ b/plugins/computers/migrations/versions/0001_computers_anchor.py @@ -0,0 +1,28 @@ +"""computers 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_computers. From this +anchor forward, new computers 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 = 'computers0001anchor' +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 diff --git a/plugins/computers/models/__init__.py b/plugins/computers/models/__init__.py new file mode 100644 index 00000000..eee75210 --- /dev/null +++ b/plugins/computers/models/__init__.py @@ -0,0 +1,17 @@ +"""Computers plugin models.""" + +from .computer import ( + Computer, + ComputerType, + ComputerInstalledApp, + AccessProtocol, + ComputerAccess, +) + +__all__ = [ + 'Computer', + 'ComputerType', + 'ComputerInstalledApp', + 'AccessProtocol', + 'ComputerAccess', +] diff --git a/plugins/computers/models/computer.py b/plugins/computers/models/computer.py new file mode 100644 index 00000000..514fe2e5 --- /dev/null +++ b/plugins/computers/models/computer.py @@ -0,0 +1,284 @@ +"""Computer plugin models.""" + +from datetime import datetime, timezone + +from shopdb.api import db, BaseModel + + +def _utcnow(): + # naive UTC to match the other DB DateTime columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class ComputerType(BaseModel): + """ + Computer type classification. + + Examples: Shopfloor PC, Engineer Workstation, CMM PC, Server, etc. + """ + __tablename__ = 'computertypes' + + computertypeid = db.Column(db.Integer, primary_key=True) + computertype = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + color = db.Column(db.String(20), comment='CSS color for UI/map markers') + + def __repr__(self): + return f"" + + +class Computer(BaseModel): + """ + Computer-specific extension data. + + Links to core Asset table via assetid. + Stores computer-specific fields like hostname, OS, logged in user, etc. + """ + __tablename__ = 'computers' + + computerid = db.Column(db.Integer, primary_key=True) + + # Link to core asset + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Computer classification + computertypeid = db.Column( + db.Integer, + db.ForeignKey('computertypes.computertypeid'), + nullable=True + ) + + # Network identity + hostname = db.Column( + db.String(100), + comment='Network hostname' + ) + + # Operating system + osid = db.Column( + db.Integer, + db.ForeignKey('operatingsystems.osid'), + nullable=True + ) + + # Hardware make/model (PCs carry vendor + model like machines) + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + modelnumberid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=True + ) + + # Status tracking + loggedinuser = db.Column(db.String(100), nullable=True) + lastreporteddate = db.Column(db.DateTime, nullable=True) + lastboottime = db.Column(db.DateTime, nullable=True) + + # Remote access is now modeled per-protocol via the accessmethods + # relationship (AccessProtocol / ComputerAccess), replacing isvnc/iswinrm. + + # Relationships + asset = db.relationship( + 'Asset', + backref=db.backref('computer', uselist=False, lazy='joined') + ) + computertype = db.relationship('ComputerType', backref='computers') + operatingsystem = db.relationship('OperatingSystem', backref='computers') + vendor = db.relationship('Vendor') + model = db.relationship('Model') + + # Installed applications (one-to-many) + installedapps = db.relationship( + 'ComputerInstalledApp', + back_populates='computer', + cascade='all, delete-orphan', + lazy='dynamic' + ) + + # Remote-access protocols enabled on this PC (replaces isvnc/iswinrm) + accessmethods = db.relationship( + 'ComputerAccess', + back_populates='computer', + cascade='all, delete-orphan', + lazy='selectin' + ) + + __table_args__ = ( + db.Index('idx_computer_type', 'computertypeid'), + db.Index('idx_computer_hostname', 'hostname'), + db.Index('idx_computer_os', 'osid'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.computertype: + result['computertypename'] = self.computertype.computertype + if self.operatingsystem: + result['osname'] = self.operatingsystem.osname + if self.vendor: + result['vendorname'] = self.vendor.vendor + if self.model: + result['modelname'] = self.model.modelnumber + if self.model.imageurl: + result['imageurl'] = self.model.imageurl + + # Names of enabled remote-access protocols (for list badges) + result['accessprotocolnames'] = [ + am.protocol.name for am in self.accessmethods + if am.isactive and am.protocol and am.protocol.isactive + ] + + return result + + +class ComputerInstalledApp(db.Model): + """ + Junction table for applications installed on computers. + + Tracks which applications are installed on which computers, + including version information. + """ + __tablename__ = 'computerinstalledapps' + + id = db.Column(db.Integer, primary_key=True) + computerid = db.Column( + db.Integer, + db.ForeignKey('computers.computerid', ondelete='CASCADE'), + nullable=False + ) + appid = db.Column( + db.Integer, + db.ForeignKey('applications.appid'), + nullable=False + ) + appversionid = db.Column( + db.Integer, + db.ForeignKey('appversions.appversionid'), + nullable=True + ) + # Raw version string from automated collection (when no curated AppVersion) + installedversion = db.Column(db.String(100), nullable=True) + isactive = db.Column(db.Boolean, default=True, nullable=False) + installeddate = db.Column(db.DateTime, default=_utcnow) + + # Relationships + computer = db.relationship('Computer', back_populates='installedapps') + application = db.relationship('Application') + appversion = db.relationship('AppVersion') + + __table_args__ = ( + db.UniqueConstraint('computerid', 'appid', name='uq_computer_app'), + db.Index('idx_compapp_computer', 'computerid'), + db.Index('idx_compapp_app', 'appid'), + ) + + def to_dict(self): + # Curated AppVersion wins; else the raw collected version string. + version = None + if self.appversion is not None: + version = self.appversion.version + elif self.installedversion: + version = self.installedversion + return { + 'id': self.id, + 'computerid': self.computerid, + 'appid': self.appid, + 'appname': self.application.appname if self.application else None, + 'appdescription': self.application.appdescription if self.application else None, + 'appversionid': self.appversionid, + 'installedversion': version, + 'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None, + 'isactive': self.isactive, + } + + +class AccessProtocol(db.Model): + """ + Catalog of remote-access protocols a PC can expose (VNC, WinRM, RDP, SSH...). + + linktemplate builds a connection URL from placeholders {host}, {port}, + {scheme}. {host} is the PC hostname joined to the pc_access_domain setting. + Admin-managed; replaces the old fixed isvnc/iswinrm booleans. + """ + __tablename__ = 'accessprotocols' + + protocolid = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), unique=True, nullable=False) + scheme = db.Column(db.String(20), nullable=False) + defaultport = db.Column(db.Integer, nullable=True) + linktemplate = db.Column(db.String(255), nullable=False) + isactive = db.Column(db.Boolean, default=True, nullable=False) + + def to_dict(self): + return { + 'protocolid': self.protocolid, + 'name': self.name, + 'scheme': self.scheme, + 'defaultport': self.defaultport, + 'linktemplate': self.linktemplate, + 'isactive': bool(self.isactive), + } + + +class ComputerAccess(db.Model): + """A protocol enabled on a specific PC, with an optional port override.""" + __tablename__ = 'computeraccess' + + id = db.Column(db.Integer, primary_key=True) + computerid = db.Column( + db.Integer, + db.ForeignKey('computers.computerid', ondelete='CASCADE'), + nullable=False + ) + protocolid = db.Column( + db.Integer, + db.ForeignKey('accessprotocols.protocolid'), + nullable=False + ) + portoverride = db.Column(db.Integer, nullable=True) + isactive = db.Column(db.Boolean, default=True, nullable=False) + + protocol = db.relationship('AccessProtocol') + computer = db.relationship('Computer', back_populates='accessmethods') + + __table_args__ = ( + db.UniqueConstraint('computerid', 'protocolid', name='uq_computer_protocol'), + db.Index('idx_compaccess_computer', 'computerid'), + ) + + def to_dict(self): + """Convert to dictionary.""" + return { + 'id': self.id, + 'computerid': self.computerid, + 'appid': self.appid, + 'appversionid': self.appversionid, + 'isactive': self.isactive, + 'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None, + 'application': { + 'appid': self.application.appid, + 'appname': self.application.appname, + 'appdescription': self.application.appdescription, + } if self.application else None, + 'version': self.appversion.version if self.appversion else None + } + + def __repr__(self): + return f"" diff --git a/plugins/computers/pctypemap.py b/plugins/computers/pctypemap.py new file mode 100644 index 00000000..320c3dca --- /dev/null +++ b/plugins/computers/pctypemap.py @@ -0,0 +1,68 @@ +"""PXE/GE-Enforce pc-type -> ComputerType mapping (computers-plugin domain). + +The imaging pipeline writes C:\\Enrollment\\pc-type.txt (the gea-shopfloor-* +taxonomy). The collector maps that to a flask ComputerType. Defaults live here; +per-site overrides live in core Settings (pctypemap_, category +'pctypemapping') and are editable in the Settings UI. This indirection means +the imaging taxonomy can change without code edits. +""" + +from shopdb.api import Setting + +# pxe pc-type -> flask ComputerType name. Defaults; overridable via settings. +DEFAULT_PCTYPE_MAP = { + 'gea-shopfloor-collections': 'Shopfloor PC', + 'gea-shopfloor-nocollections': 'Shopfloor PC', + 'gea-shopfloor-common': 'Shopfloor PC', + 'gea-shopfloor-keyence': 'Shopfloor PC', + 'gea-shopfloor-cmm': 'CMM PC', + 'gea-shopfloor-genspect': 'Shopfloor PC', + 'gea-shopfloor-heattreat': 'Shopfloor PC', + 'gea-shopfloor-waxtrace': 'Shopfloor PC', + 'gea-shopfloor-display': 'Kiosk', + 'gea-shopfloor-partmarker': 'Shopfloor PC', +} + +# DEPRECATED settings surface: the "Collector PC Types" settings page is retired +# (GE-Enforce scope computertypeid supersedes it, ADR-012). Collector still reads +# this map, so keep the code; only the editor UI card is gone. +_SETTING_PREFIX = 'pctypemap_' +_SETTING_CATEGORY = 'pctypemapping' + +# Metrology imaging pc-types: the PC itself stays a shopfloor PC, but it drives +# an attached measuring instrument. When the collector sees one of these, it +# auto-creates a MeasuringTool asset for the device and links the PC to it with +# a "controls" relationship (see ComputersPlugin._sync_measuringtool_link). +# Maps imaging pc-type -> (MeasuringToolType name, type description). The type +# is created on demand if the measuringtools plugin has not seeded it. +METROLOGY_TOOL_MAP = { + 'gea-shopfloor-cmm': ('CMM', 'Coordinate measuring machine'), + 'gea-shopfloor-keyence': ('Vision System', 'Optical / vision measurement system (Keyence)'), + 'gea-shopfloor-genspect': ('Genspect', 'Genspect visual / borescope inspection system'), + 'gea-shopfloor-waxtrace': ('Form Tracer', 'Surface / form tracing system (wax and trace)'), +} + + +def metrology_tool_for(pctype): + """Return (typename, typedescription) if this pc-type drives a measuring + tool, else None.""" + return METROLOGY_TOOL_MAP.get((pctype or '').strip()) + + +def pctype_mapping(): + """Return the live pctype -> ComputerType-name map (settings over defaults).""" + mapping = dict(DEFAULT_PCTYPE_MAP) + for setting in Setting.query.filter_by(category=_SETTING_CATEGORY).all(): + if setting.key.startswith(_SETTING_PREFIX): + mapping[setting.key[len(_SETTING_PREFIX):]] = setting.get_typed_value() + return mapping + + +def seed_pctype_settings(): + """Seed the pctypemap_ settings from defaults (idempotent).""" + for pxetype, computertype in DEFAULT_PCTYPE_MAP.items(): + key = f'{_SETTING_PREFIX}{pxetype}' + if not Setting.query.filter_by(key=key).first(): + Setting.set(key, computertype, valuetype='string', + category=_SETTING_CATEGORY, + description=f'Collector maps pc-type {pxetype} to this ComputerType') diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py new file mode 100644 index 00000000..fee77748 --- /dev/null +++ b/plugins/computers/plugin.py @@ -0,0 +1,680 @@ +"""Computers plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint +import click + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db, AssetType + +from .models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess +from .api import computers_bp + +logger = logging.getLogger(__name__) + +# Marker stamped on PC->printer links this collector creates. The stale-link +# archive only touches rows carrying this label, so manually-created printer +# relationships are never removed by a collector push. Stored in the +# assetrelationships.label column (no origin column exists; see BUILD notes). +PRINTER_LINK_ORIGIN = 'collector:printers' + +# Marker stamped on PC->measuringtool "controls" links this collector creates +# for metrology PCs (CMM, Keyence, Genspect, wax-and-trace). Same stale-link +# discipline as PRINTER_LINK_ORIGIN: only rows carrying this label are archived +# by a collector push, so hand-made tool links survive. +MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool' + + +class ComputersPlugin(BasePlugin): + """ + Computers plugin - manages PC, server, and workstation assets. + + Computers include shopfloor PCs, engineer workstations, servers, etc. + Uses the new Asset architecture with Computer extension table. + """ + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + manifestpath = Path(__file__).parent / 'manifest.json' + if manifestpath.exists(): + with open(manifestpath, 'r') as f: + return json.load(f) + return {} + + @property + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'computers'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Computer management for PCs, servers, and workstations' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/computers'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return computers_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Computers plugin initialized (v{self.meta.version})") + + # -- ADR-006 collector contract ----------------------------------------- + + def get_collector_schema(self) -> Optional[Dict]: + """Schema for the PC collector payload (matched by hostname). + + Aligns with the GE-Enforce status shape (transport may change), using + the project naming convention (lowercase concatenated). The caller maps + its own field names to these. + """ + return { + 'identityfield': 'hostname', + 'fields': { + 'hostname': {'type': 'string', 'required': True}, + 'machinenumber': {'type': 'string'}, + 'pctype': {'type': 'string'}, + 'pcsubtype': {'type': 'string'}, + 'serialnumber': {'type': 'string'}, + 'loggedinuser': {'type': 'string'}, + 'lastboottime': {'type': 'string', 'format': 'date-time'}, + 'lastcheckin': {'type': 'string', 'format': 'date-time'}, + 'ipaddress': {'type': 'string'}, + 'vendorname': {'type': 'string'}, + 'modelnumber': {'type': 'string'}, + 'osname': {'type': 'string'}, + 'installedsoftware': { + 'type': 'array', + 'items': {'name': 'string', 'version': 'string'}, + }, + # Printer identifiers reported by the GE-Enforce side, which + # runs Get-CimInstance Win32_Printer and marks the default with + # the Default flag. An identifier is the printer's windows name, + # share name, hostname, or port/IP; the collector resolves it + # flexibly to a printer asset. Both optional. Presence of either + # key drives the PC->printer relationship sync (and stale-link + # archive); absence leaves existing printer links untouched. + 'defaultprinter': { + 'type': 'string', + 'description': ('Default printer identifier (Win32_Printer ' + 'with Default=true): windows name, share ' + 'name, hostname, or port IP.'), + }, + 'printers': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': ('All installed network printer identifiers ' + '(Win32_Printer): windows name / share / ' + 'hostname / IP. Unresolved -> warning.'), + }, + }, + } + + def apply_collector_payload(self, payload: Dict) -> Dict: + """Idempotent upsert of a PC from a collector payload (by hostname).""" + from datetime import datetime, timezone + from shopdb.api import ( + Asset, Application, Communication, CommunicationType, + Vendor, Model, OperatingSystem, + ) + from .pctypemap import pctype_mapping + + warnings = [] + hostname = (payload.get('hostname') or '').strip() + if not hostname: + raise ValueError('hostname is required') + + # Machine number is the business identifier (Asset.assetnumber). Skip + # the imaging-time placeholder '9999' and fall back to hostname. + machinenumber = (payload.get('machinenumber') or '').strip() + if machinenumber in ('', '9999'): + machinenumber = None + + comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first() + if not comp: + comp = (Computer.query.join(Asset, Asset.assetid == Computer.assetid) + .filter(Asset.assetnumber.ilike(hostname)).first()) + + action = 'updated' + if not comp: + atype = AssetType.query.filter_by(assettype='computer').first() + # statusid=1 is the first seeded asset status ("In Use"); a + # collector-discovered PC is by definition in use. + asset = Asset(assetnumber=machinenumber or hostname, + assettypeid=atype.assettypeid, statusid=1) + db.session.add(asset) + db.session.flush() + comp = Computer(assetid=asset.assetid, hostname=hostname) + db.session.add(comp) + db.session.flush() + action = 'created' + elif machinenumber and comp.asset: + comp.asset.assetnumber = machinenumber + + comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None) + if payload.get('lastboottime'): + try: + comp.lastboottime = datetime.fromisoformat( + payload['lastboottime'].replace('Z', '+00:00')) + except (ValueError, AttributeError): + warnings.append('lastboottime not parseable') + loggedinuser = payload.get('loggedinuser') or payload.get('currentuser') + if loggedinuser: + comp.loggedinuser = loggedinuser + if payload.get('serialnumber') and comp.asset: + comp.asset.serialnumber = payload['serialnumber'] + + # pc-type -> ComputerType via the configurable settings mapping. + pctype = (payload.get('pctype') or '').strip() + if pctype: + from .models import ComputerType + mapped = pctype_mapping().get(pctype) + if not mapped: + warnings.append(f'no ComputerType mapping for pctype: {pctype}') + else: + ctype = ComputerType.query.filter_by(computertype=mapped).first() + if ctype: + comp.computertypeid = ctype.computertypeid + else: + warnings.append(f'mapped ComputerType not found: {mapped}') + + # Vendor / model are free vocab - create if missing. + vendorname = (payload.get('vendorname') or '').strip() + vendor = None + if vendorname: + vendor = Vendor.query.filter(Vendor.vendor.ilike(vendorname)).first() + if not vendor: + vendor = Vendor(vendor=vendorname) + db.session.add(vendor) + db.session.flush() + comp.vendorid = vendor.vendorid + + modelnumber = (payload.get('modelnumber') or '').strip() + if modelnumber: + model_query = Model.query.filter(Model.modelnumber.ilike(modelnumber)) + if vendor: + model_query = model_query.filter(Model.vendorid == vendor.vendorid) + model = model_query.first() + if not model: + model = Model(modelnumber=modelnumber, + vendorid=vendor.vendorid if vendor else None) + db.session.add(model) + db.session.flush() + comp.modelnumberid = model.modelnumberid + + # OS is a controlled vocab - look up only, warn if unknown. + osname = (payload.get('osname') or '').strip() + if osname: + os_row = OperatingSystem.query.filter( + OperatingSystem.osname.ilike(osname)).first() + if os_row: + comp.osid = os_row.osid + else: + warnings.append(f'unknown operating system: {osname}') + + if payload.get('pcsubtype'): + warnings.append('pcsubtype received but not stored (no model field)') + + if payload.get('ipaddress'): + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + primary = Communication.query.filter_by( + assetid=comp.assetid, isprimary=True).first() + if primary: + primary.ipaddress = payload['ipaddress'] + elif ip_comtype: + db.session.add(Communication( + assetid=comp.assetid, comtypeid=ip_comtype.comtypeid, + ipaddress=payload['ipaddress'], isprimary=True)) + + for app_data in payload.get('installedsoftware', []) or []: + name = app_data.get('name') + if not name: + continue + app = Application.query.filter(Application.appname.ilike(name)).first() + if not app: + warnings.append(f'unknown application: {name}') + continue + installed = ComputerInstalledApp.query.filter_by( + computerid=comp.computerid, appid=app.appid).first() + version = app_data.get('version') + if installed: + installed.installedversion = version + installed.isactive = True + else: + db.session.add(ComputerInstalledApp( + computerid=comp.computerid, appid=app.appid, + installedversion=version)) + + # Printer relationship sync (only when the payload carried printer data). + printerlinks = self._sync_printer_links(comp.asset, payload, warnings) + + # Measuring-tool sync: metrology PCs (CMM/Keyence/Genspect/wax-trace) + # get an attached MeasuringTool asset auto-created and linked. + measuringtoollinks = self._sync_measuringtool_link( + comp.asset, pctype, hostname, warnings) + + db.session.commit() + return { + 'action': action, + 'assetid': comp.assetid, + 'identityvalue': hostname, + 'warnings': warnings, + 'extra': { + 'printerlinks': printerlinks, + 'printerlinkcount': len(printerlinks), + 'measuringtoollinks': measuringtoollinks, + 'measuringtoollinkcount': len(measuringtoollinks), + }, + } + + # -- printer relationship sync ----------------------------------------- + + def _sync_printer_links(self, pcasset, payload, warnings): + """Idempotently sync PC->printer relationships from collector printer data. + + Default printer -> 'defaultprinter' (directional). Other reported + printers -> 'connectedto' (symmetric). Resolves each identifier to a + printer asset by windows name / share / hostname / asset number / name + or a communications IP. Unresolved identifiers add a warning and never + fail the push. + + Stale-link archive: on each push, collector-tagged links (label == + PRINTER_LINK_ORIGIN) whose (target, type) pair is not in the reported + desired set are set inactive. Only tagged rows are touched, so manual + links survive. Runs only when the payload carried a printer key + ('defaultprinter' or 'printers'); a PC that reports without printer data + keeps its existing links. Returns the desired-link list (created + kept). + """ + from shopdb.api import ( + AssetRelationship, RelationshipType, Asset, Communication) + + has_default = 'defaultprinter' in payload + has_list = 'printers' in payload + if not has_default and not has_list: + return [] + + try: + from plugins.printers.models import Printer + except ImportError: + warnings.append('printers plugin unavailable; printer links skipped') + return [] + + dp_type = RelationshipType.query.filter_by( + relationshiptype='defaultprinter').first() + ct_type = RelationshipType.query.filter_by( + relationshiptype='connectedto').first() + if not dp_type or not ct_type: + warnings.append('printer relationship types missing; ' + 'run flask seed reference-data') + return [] + + def resolve(identifier): + # first match wins: printer text identity, then a printer IP. + ident = (identifier or '').strip() + if not ident: + return None + printer = ( + Printer.query.join(Asset, Asset.assetid == Printer.assetid) + .filter(Asset.isactive == True) + .filter(db.or_( + Printer.windowsname.ilike(ident), + Printer.hostname.ilike(ident), + Printer.sharename.ilike(ident), + Asset.assetnumber.ilike(ident), + Asset.name.ilike(ident), + )).first()) + if printer: + return printer.asset + comm = ( + db.session.query(Communication) + .join(Printer, Printer.assetid == Communication.assetid) + .filter(Communication.ipaddress == ident) + .first()) + if comm: + return db.session.get(Asset, comm.assetid) + return None + + pcid = pcasset.assetid + desired = set() # (targetassetid, relationshiptypeid) to keep + printerlinks = [] + + default_id = None + default_ident = (payload.get('defaultprinter') or '').strip() + if default_ident: + target = resolve(default_ident) + if target: + default_id = target.assetid + desired.add((default_id, dp_type.relationshiptypeid)) + self._sync_one(pcid, default_id, dp_type) + printerlinks.append({'assetid': default_id, + 'relationshiptype': 'defaultprinter'}) + else: + warnings.append(f'unresolved default printer: {default_ident}') + + for ident in payload.get('printers') or []: + target = resolve(ident) + if not target: + warnings.append(f'unresolved printer: {ident}') + continue + if target.assetid == default_id: + continue # already the default link + desired.add((target.assetid, ct_type.relationshiptypeid)) + self._sync_one(pcid, target.assetid, ct_type) + printerlinks.append({'assetid': target.assetid, + 'relationshiptype': 'connectedto'}) + + # Archive collector-tagged links no longer reported (manual links, with + # a NULL/other label, are never matched here). + collector_rows = AssetRelationship.query.filter( + AssetRelationship.sourceassetid == pcid, + AssetRelationship.relationshiptypeid.in_( + [dp_type.relationshiptypeid, ct_type.relationshiptypeid]), + AssetRelationship.isactive == True, + AssetRelationship.label == PRINTER_LINK_ORIGIN, + ).all() + for rel in collector_rows: + if (rel.targetassetid, rel.relationshiptypeid) not in desired: + rel.isactive = False + + return printerlinks + + def _sync_one(self, pcid, printerid, reltype): + """Reactivate or create one collector PC->printer link (idempotent).""" + from shopdb.api import AssetRelationship + existing = AssetRelationship.query.filter_by( + sourceassetid=pcid, targetassetid=printerid, + relationshiptypeid=reltype.relationshiptypeid).first() + if existing: + # do not re-stamp label: a pre-existing manual row stays manual. + if not existing.isactive: + existing.isactive = True + return existing + rel = AssetRelationship( + sourceassetid=pcid, targetassetid=printerid, + relationshiptypeid=reltype.relationshiptypeid, + label=PRINTER_LINK_ORIGIN) + db.session.add(rel) + return rel + + # -- measuring-tool sync ----------------------------------------------- + + def _sync_measuringtool_link(self, pcasset, pctype, hostname, warnings): + """Auto-create + link the MeasuringTool a metrology PC drives. + + A CMM / Keyence / Genspect / wax-and-trace imaging pc-type means the + shopfloor PC controls an attached measuring instrument. This creates + that instrument once as a MeasuringTool asset and a directional + PC->tool 'controls' relationship, tagged MEASURINGTOOL_LINK_ORIGIN so + it is idempotent and self-archiving. The PC's own ComputerType is left + alone (it stays a shopfloor PC). A non-metrology pc-type archives any + collector-created tool link (e.g. a PC re-imaged to another type) but + never deletes the tool asset, which may carry calibration history. + Returns the desired-link list. + """ + from shopdb.api import AssetRelationship, RelationshipType, Asset + from .pctypemap import metrology_tool_for + + tool_spec = metrology_tool_for(pctype) + controls = RelationshipType.query.filter_by( + relationshiptype='controls').first() + if not controls: + # No 'controls' type => no tool links can exist. Only a problem for a + # metrology PC that needs one; stay quiet for ordinary PCs. + if tool_spec: + warnings.append("'controls' relationship type missing; " + 'run flask seed reference-data') + return [] + + # Collector-created tool links already on this PC (active or archived). + existing = AssetRelationship.query.filter( + AssetRelationship.sourceassetid == pcasset.assetid, + AssetRelationship.relationshiptypeid == controls.relationshiptypeid, + AssetRelationship.label == MEASURINGTOOL_LINK_ORIGIN, + ).all() + + if not tool_spec: + # Not a metrology PC: archive any collector-created tool link. + for rel in existing: + if rel.isactive: + rel.isactive = False + return [] + + try: + from plugins.measuringtools.models import MeasuringTool + except ImportError: + warnings.append('measuringtools plugin unavailable; ' + 'tool link skipped') + return [] + + typename, typedescription = tool_spec + tooltype = self._ensure_measuringtool_type(typename, typedescription) + + # Reuse any prior collector link (reactivate + retype) before creating, + # so a re-metrology PC never duplicates the tool asset. + reuse = next((rel for rel in existing if rel.isactive), None) \ + or (existing[0] if existing else None) + if reuse: + reuse.isactive = True + toolasset = db.session.get(Asset, reuse.targetassetid) + if toolasset and toolasset.measuringtool and tooltype: + toolasset.measuringtool.measuringtooltypeid = \ + tooltype.measuringtooltypeid + targetid = reuse.targetassetid + else: + mt_assettype = AssetType.query.filter_by( + assettype='measuring_tool').first() + if not mt_assettype: + warnings.append('measuring_tool asset type missing; ' + 'tool link skipped') + return [] + suffix = (pctype or '').split('-')[-1].upper() + baseasset = pcasset.assetnumber or hostname + toolasset = Asset( + assetnumber=f'{baseasset}-{suffix}', + name=f'{typename} ({hostname})', + assettypeid=mt_assettype.assettypeid, + statusid=1) + db.session.add(toolasset) + db.session.flush() + db.session.add(MeasuringTool( + assetid=toolasset.assetid, + measuringtooltypeid=tooltype.measuringtooltypeid + if tooltype else None)) + db.session.add(AssetRelationship( + sourceassetid=pcasset.assetid, + targetassetid=toolasset.assetid, + relationshiptypeid=controls.relationshiptypeid, + label=MEASURINGTOOL_LINK_ORIGIN)) + targetid = toolasset.assetid + + # Only one tool link is desired; archive any other collector rows. + for rel in existing: + if rel is not reuse and rel.isactive: + rel.isactive = False + + return [{'assetid': targetid, 'relationshiptype': 'controls', + 'measuringtooltype': typename}] + + def _ensure_measuringtool_type(self, name, description): + """Find or create a MeasuringToolType (metrology types are not in the + measuringtools starter seed).""" + from plugins.measuringtools.models import MeasuringToolType + tooltype = MeasuringToolType.query.filter_by(name=name).first() + if not tooltype: + tooltype = MeasuringToolType(name=name, description=description) + db.session.add(tooltype) + db.session.flush() + return tooltype + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensure_asset_type() + self._ensure_computer_types() + from .pctypemap import seed_pctype_settings + seed_pctype_settings() + db.session.commit() + logger.info("Computers plugin installed") + + def _ensure_asset_type(self) -> None: + """Ensure computer asset type exists.""" + existing = AssetType.query.filter_by(assettype='computer').first() + if not existing: + at = AssetType( + assettype='computer', + pluginname='computers', + tablename='computers', + description='PCs, servers, and workstations', + icon='desktop' + ) + db.session.add(at) + logger.debug("Created asset type: computer") + db.session.commit() + + def _ensure_computer_types(self) -> None: + """Ensure basic computer types exist.""" + computer_types = [ + ('Shopfloor PC', 'PC located on the shop floor for machine operation', 'desktop'), + ('Engineer Workstation', 'Engineering workstation for CAD/CAM work', 'laptop'), + ('CMM PC', 'PC dedicated to CMM operation', 'desktop'), + ('Server', 'Server system', 'server'), + ('Kiosk', 'Kiosk or info display PC', 'tv'), + ('Laptop', 'Laptop computer', 'laptop'), + ('Virtual Machine', 'Virtual machine', 'cloud'), + ('Other', 'Other computer type', 'desktop'), + ] + + for name, description, icon in computer_types: + existing = ComputerType.query.filter_by(computertype=name).first() + if not existing: + ct = ComputerType( + computertype=name, + description=description, + icon=icon + ) + db.session.add(ct) + logger.debug(f"Created computer type: {name}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Computers plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('computers') + def computerscli(): + """Computers plugin commands.""" + pass + + @computerscli.command('list-types') + def list_types(): + """List all computer types.""" + from flask import current_app + + with current_app.app_context(): + types = ComputerType.query.filter_by(isactive=True).all() + if not types: + click.echo('No computer types found.') + return + + click.echo('Computer Types:') + for t in types: + click.echo(f" [{t.computertypeid}] {t.computertype}") + + @computerscli.command('stats') + def stats(): + """Show computer statistics.""" + from flask import current_app + from shopdb.api import Asset + + with current_app.app_context(): + total = db.session.query(Computer).join(Asset).filter( + Asset.isactive == True + ).count() + + click.echo(f"Total active computers: {total}") + + # Shopfloor count (by the Shopfloor computer type) + sf = ComputerType.query.filter_by(computertype='Shopfloor').first() + shopfloor = db.session.query(Computer).join(Asset).filter( + Asset.isactive == True, + Computer.computertypeid == (sf.computertypeid if sf else -1) + ).count() + + click.echo(f" Shopfloor PCs: {shopfloor}") + click.echo(f" Other: {total - shopfloor}") + + @computerscli.command('find') + @click.argument('hostname') + def find_by_hostname(hostname): + """Find a computer by hostname.""" + from flask import current_app + + with current_app.app_context(): + comp = Computer.query.filter( + Computer.hostname.ilike(f'%{hostname}%') + ).first() + + if not comp: + click.echo(f'No computer found matching hostname: {hostname}') + return + + click.echo(f'Found: {comp.hostname}') + click.echo(f' Asset: {comp.asset.assetnumber}') + click.echo(f' Type: {comp.computertype.computertype if comp.computertype else "N/A"}') + click.echo(f' OS: {comp.operatingsystem.osname if comp.operatingsystem else "N/A"}') + click.echo(f' Logged in: {comp.loggedinuser or "N/A"}') + + return [computerscli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Computer Status', + 'component': 'ComputerStatusWidget', + 'endpoint': '/api/computers/dashboard/summary', + 'size': 'medium', + 'position': 6, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'PCs', + 'icon': 'desktop', + 'route': '/pcs', + 'position': 15, + }, + ] + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('computers.view', 'View computers', 'computers'), + ('computers.create', 'Create computers', 'computers'), + ('computers.edit', 'Edit computers', 'computers'), + ('computers.delete', 'Delete computers', 'computers'), + ] diff --git a/plugins/employees/README.md b/plugins/employees/README.md new file mode 100644 index 00000000..8912c9e8 --- /dev/null +++ b/plugins/employees/README.md @@ -0,0 +1,133 @@ +# Employees plugin - directory database contract + +Read-only lookups against a **separate** employee/HR directory database. Powers: + +- Employee search (add people to a notification, browse the directory) +- Single + batch SSO lookup +- Recognition notifications (name + photo on the shopfloor / lobby displays) + +The plugin never writes to this database. Use a read-only account. + +> **This is the integration most likely to differ per site.** HR / directory +> systems vary widely, so expect to map a site's schema to the contract below - +> the `CREATE VIEW` recipe at the end is the normal way to do it. + +## Connection + +Credentials resolve **settings-first, then environment**, except the password +which is env-only (never stored in the app database). + +| Field | Setting key (editable in the setup wizard) | Env var (fallback) | +| -------- | ------------------------------------------ | ---------------------- | +| Host | `employee_db_host` | `EMPLOYEE_DB_HOST` | +| Database | `employee_db_name` | `EMPLOYEE_DB_NAME` | +| User | `employee_db_user` | `EMPLOYEE_DB_USER` | +| Password | (not stored) | `EMPLOYEE_DB_PASSWORD` | + +Set the password in `.env`; the setup wizard's Features step shows the exact +line to paste. Engine is MySQL/MariaDB (pymysql). + +## Required schema + +The plugin runs these queries verbatim, so a site's database must expose a +table (or **view** - see below) named `employees` with these columns: + +| Column | Type | Notes | +| ------------ | ------------------- | ------------------------------------------------- | +| `SSO` | INT | Unique person id. Lookups require it to be numeric | +| `First_Name` | VARCHAR | Displayed as the given name | +| `Last_Name` | VARCHAR | Displayed as the surname; sort key | +| `Team` | VARCHAR | Team / group label | +| `Role` | VARCHAR | Job title / role | +| `Picture` | VARCHAR | **Image filename** (see Photos) | + +Queries actually executed: + +```sql +-- search +SELECT SSO, First_Name, Last_Name, Team, Role, Picture +FROM employees +WHERE First_Name LIKE %s OR Last_Name LIKE %s OR CAST(SSO AS CHAR) LIKE %s +ORDER BY Last_Name, First_Name LIMIT %s; + +-- single / batch +SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO = %s; +SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO IN (...); +``` + +Rows are returned to the API **with these exact column names**. The frontend +(EmployeeSearch, NotificationForm, EmployeeDetail, ShopfloorDashboard) reads +`SSO`, `First_Name`, `Last_Name`, `Team`, `Role`, `Picture` as-is - do not +rename them in the response. + +## Photos + +`Picture` holds an **image filename** (e.g. `123456.jpg`), not a path or blob. +The app renders it as `/static/employees/`, so the image files must +live in the app's `static/employees/` directory. Leave `Picture` empty/NULL for +people with no photo; the UI falls back to initials. + +## Two ways to provide the directory + +**Option A - map an existing HR/directory database** (see the view recipe +below). Use this when the site already has a system of record for people. + +**Option B - self-hosted directory (managed in-app)** for sites with no HR +database. Set `employee_directory_mode` to `selfhosted` and manage people under +**Settings > Employee Directory** (add / edit / delete + CSV import) - no SQL +needed. The app owns a `directoryemployees` table (migration 7d16); the lookup +APIs read it automatically in this mode. + +If you would rather load it with SQL, the canonical table is equivalent to: + +```sql +CREATE DATABASE shopdb_directory CHARACTER SET utf8mb4; +USE shopdb_directory; + +CREATE TABLE employees ( + SSO INT NOT NULL PRIMARY KEY, + First_Name VARCHAR(100) NOT NULL, + Last_Name VARCHAR(100) NOT NULL, + Team VARCHAR(100) NULL, + Role VARCHAR(100) NULL, + Picture VARCHAR(255) NULL +); + +-- add people (or bulk-load from CSV with LOAD DATA INFILE) +INSERT INTO employees (SSO, First_Name, Last_Name, Team, Role, Picture) +VALUES (123456, 'Jane', 'Doe', 'Inspection', 'Quality Tech', '123456.jpg'); +``` + +Put photo files (named as in `Picture`) under the app's `static/employees/`. +In self-hosted mode the `directoryemployees` table lives in the main app DB, so +no separate database is required. + +## Adapting a different site schema (recommended: a view) + +Sites whose HR/directory database uses different table or column names should +**not** be forced to rename anything. Instead, create a read-only **view** +named `employees` that maps local columns to the names above: + +```sql +CREATE VIEW employees AS +SELECT + person_id AS SSO, + given_name AS First_Name, + surname AS Last_Name, + department AS Team, + job_title AS Role, + photo_filename AS Picture +FROM hr_people +WHERE active = 1; +``` + +Grant the app's read-only user `SELECT` on the view. No app code changes - +point `employee_db_*` at that database and the plugin works. + +Notes: +- `SSO` must be numeric (single/batch lookup validate `isdigit()`). +- Column-name case follows your database's identifier casing; match the names + above exactly on case-sensitive platforms. +- If the directory is unreachable or the `employees` object is missing, lookups + return a 500 and the rest of the app keeps working (the feature degrades, it + does not crash the app). diff --git a/plugins/employees/__init__.py b/plugins/employees/__init__.py new file mode 100644 index 00000000..b2ff6752 --- /dev/null +++ b/plugins/employees/__init__.py @@ -0,0 +1,5 @@ +"""Employees plugin package.""" + +from .plugin import EmployeesPlugin + +__all__ = ['EmployeesPlugin'] diff --git a/plugins/employees/api/__init__.py b/plugins/employees/api/__init__.py new file mode 100644 index 00000000..50aefcc3 --- /dev/null +++ b/plugins/employees/api/__init__.py @@ -0,0 +1,5 @@ +"""Employees plugin API.""" + +from .routes import employees_bp + +__all__ = ['employees_bp'] diff --git a/plugins/employees/api/routes.py b/plugins/employees/api/routes.py new file mode 100644 index 00000000..0fb8f024 --- /dev/null +++ b/plugins/employees/api/routes.py @@ -0,0 +1,504 @@ +"""Employee lookup API endpoints. + +These read from the separate employee directory DB (employee_connection is core +infrastructure exposed via shopdb.api, shared with search + notifications). The +endpoints are intentionally reachable by the unauthenticated shopfloor kiosk +displays (recognition wall), so they are not JWT-gated; keep them read-only and +never return more than the directory fields below. +""" + +import csv +import glob +import io +import logging +import os + +from flask import Blueprint, request, current_app, send_from_directory +from flask_jwt_extended import jwt_required +from werkzeug.utils import secure_filename + +from shopdb.api import ( + db, + success_response, + error_response, + ErrorCodes, + employee_connection, + require_role, +) +from shopdb.api import Setting + +from ..models import DirectoryEmployee + +logger = logging.getLogger(__name__) + +employees_bp = Blueprint('employees', __name__) + +# Columns safe to expose to the directory/recognition UI +_FIELDS = 'SSO, First_Name, Last_Name, Team, Role, Picture' + +# Uploaded self-hosted employee photos live in the instance dir and are served +# publicly (kiosk recognition/recertification cards read them without auth). +EMPLOYEE_PHOTO_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'} + +# URL prefix a served upload resolves to (self-hosted mode). +EMPLOYEE_PHOTO_URL_PREFIX = '/api/employees/photo/' + +# URL prefix external HR relative picture paths resolve under. The HR employees +# table stores Picture as a relative path (e.g. 'Support/210009518.png') that +# the site serves from /static/employees/; this matches the shopfloor feed. +EMPLOYEE_PHOTO_STATIC_PREFIX = '/static/employees/' + + +def _employeephoto_dir(): + return os.path.join(current_app.instance_path, 'employeephotos') + + +def _selfhosted(): + """True when the directory is the app-owned table, not an external HR DB.""" + row = Setting.query.filter_by(key='employee_directory_mode').first() + return (row.value if row and row.value else 'external').lower() == 'selfhosted' + + +def _require_selfhosted(): + """Guard for management endpoints - only valid in self-hosted mode.""" + if not _selfhosted(): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Directory is in external mode; manage people in the source HR database.', + http_code=400) + return None + + +def _require_selfhosted_photo(): + """Guard for photo write endpoints - 409 when the directory is external. + + In external mode the photo is owned by the HR database (read-only + pass-through), so upload/delete cannot apply here.""" + if not _selfhosted(): + return error_response( + ErrorCodes.CONFLICT, + 'Employee directory is external; photos are supplied by the HR ' + 'database and cannot be uploaded or deleted here.', + http_code=409) + return None + + +def _external_photo_url(picture): + """Turn an external HR Picture value into a usable URL, or None. + + Absolute URLs and already-rooted paths pass through untouched (future + full-URL HR feeds); a bare relative path is served under the static prefix + (current WJ convention, e.g. 'Support/210009518.png').""" + if not picture: + return None + text = str(picture).strip() + if not text: + return None + if text.startswith(('http://', 'https://', '/')): + return text + return EMPLOYEE_PHOTO_STATIC_PREFIX + text + + +def _hr_picture(sso): + """Raw Picture value for an SSO from the external HR directory. None on miss.""" + try: + conn = employee_connection() + with conn.cursor() as cur: + cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),)) + row = cur.fetchone() + conn.close() + return row.get('Picture') if row else None + except Exception: + return None + + +def resolve_employee_photo_url(sso, external_picture=None): + """Single resolver both consumers share: the display photo URL for an SSO. + + Self-hosted: the served upload URL when the employee has an uploaded photo, + else None (the legacy Picture text field does not drive the photo here). + External: the HR-supplied Picture resolved to a URL (pass external_picture to + avoid a re-query), else None. Returns None on any miss or bad SSO.""" + if sso is None or not str(sso).isdigit(): + return None + if _selfhosted(): + emp = db.session.get(DirectoryEmployee, int(sso)) + if emp and emp.photofilename: + return EMPLOYEE_PHOTO_URL_PREFIX + emp.photofilename + return None + picture = external_picture if external_picture is not None else _hr_picture(sso) + return _external_photo_url(picture) + + +def _with_photo_url(employee): + """Add the resolved photourl to an employee dict (self-hosted or external).""" + employee['photourl'] = resolve_employee_photo_url( + employee.get('SSO'), employee.get('Picture')) + return employee + + +@employees_bp.route('/search', methods=['GET']) +def search_employees(): + """ + Search employees by name. + + Query parameters: + - q: Search query (searches first and last name) + - limit: Max results (default 10) + """ + query = request.args.get('q', '').strip() + limit = min(int(request.args.get('limit', 10)), 50) + + if len(query) < 2: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Search query must be at least 2 characters' + ) + + if _selfhosted(): + term = f'%{query}%' + rows = (DirectoryEmployee.query + .filter(db.or_(DirectoryEmployee.firstname.ilike(term), + DirectoryEmployee.lastname.ilike(term), + db.cast(DirectoryEmployee.sso, db.String).ilike(term))) + .order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname) + .limit(limit).all()) + return success_response([_with_photo_url(e.to_dict()) for e in rows]) + + try: + conn = employee_connection() + with conn.cursor() as cur: + cur.execute(f''' + SELECT {_FIELDS} + FROM employees + WHERE First_Name LIKE %s + OR Last_Name LIKE %s + OR CAST(SSO AS CHAR) LIKE %s + ORDER BY Last_Name, First_Name + LIMIT %s + ''', (f'%{query}%', f'%{query}%', f'%{query}%', limit)) + employees = cur.fetchall() + conn.close() + return success_response([_with_photo_url(e) for e in employees]) + except Exception: + logger.exception('Employee search failed') + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Employee lookup failed', + http_code=500 + ) + + +@employees_bp.route('/lookup/', methods=['GET']) +def lookup_employee(sso): + """Look up a single employee by SSO.""" + if not sso.isdigit(): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'SSO must be numeric' + ) + + if _selfhosted(): + emp = db.session.get(DirectoryEmployee, int(sso)) + if not emp: + return error_response(ErrorCodes.NOT_FOUND, + f'Employee with SSO {sso} not found', http_code=404) + return success_response(_with_photo_url(emp.to_dict())) + + try: + conn = employee_connection() + with conn.cursor() as cur: + cur.execute( + f'SELECT {_FIELDS} FROM employees WHERE SSO = %s', + (int(sso),) + ) + employee = cur.fetchone() + conn.close() + + if not employee: + return error_response( + ErrorCodes.NOT_FOUND, + f'Employee with SSO {sso} not found', + http_code=404 + ) + + return success_response(_with_photo_url(employee)) + except Exception: + logger.exception('Employee lookup failed for SSO %s', sso) + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Employee lookup failed', + http_code=500 + ) + + +@employees_bp.route('/lookup', methods=['GET']) +def lookup_employees(): + """ + Look up multiple employees by SSO list. + + Query parameters: + - sso: Comma-separated list of SSOs + """ + sso_list = request.args.get('sso', '') + ssos = [s.strip() for s in sso_list.split(',') if s.strip().isdigit()] + + if not ssos: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'At least one valid SSO is required' + ) + + if _selfhosted(): + rows = DirectoryEmployee.query.filter( + DirectoryEmployee.sso.in_([int(s) for s in ssos])).all() + employees = [_with_photo_url(e.to_dict()) for e in rows] + names = ', '.join(f"{e['First_Name'].strip()} {e['Last_Name'].strip()}" + for e in employees) + return success_response({'employees': employees, 'names': names}) + + try: + conn = employee_connection() + with conn.cursor() as cur: + placeholders = ','.join(['%s'] * len(ssos)) + cur.execute( + f'SELECT {_FIELDS} FROM employees WHERE SSO IN ({placeholders})', + [int(s) for s in ssos] + ) + employees = cur.fetchall() + conn.close() + + employees = [_with_photo_url(e) for e in employees] + names = ', '.join( + f"{e['First_Name'].strip()} {e['Last_Name'].strip()}" + for e in employees + ) + + return success_response({ + 'employees': employees, + 'names': names + }) + except Exception: + logger.exception('Employee multi-lookup failed') + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Employee lookup failed', + http_code=500 + ) + + +# ============================================================================= +# Self-hosted directory management (only when directory_mode=selfhosted) +# ============================================================================= + +@employees_bp.route('/directory', methods=['GET']) +@jwt_required(optional=True) +def list_directory(): + """Full self-hosted directory (for the management page).""" + guard = _require_selfhosted() + if guard: + return guard + rows = (DirectoryEmployee.query + .order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname).all()) + return success_response([_with_photo_url(e.to_dict()) for e in rows]) + + +def _employee_from_payload(data): + """Build kwargs from a payload accepting either external-style (SSO, + First_Name...) or plain (sso, firstname...) keys.""" + def pick(*keys): + for key in keys: + if data.get(key) not in (None, ''): + return data.get(key) + return None + return { + 'sso': pick('sso', 'SSO'), + 'firstname': pick('firstname', 'First_Name'), + 'lastname': pick('lastname', 'Last_Name'), + 'team': pick('team', 'Team'), + 'role': pick('role', 'Role'), + 'picture': pick('picture', 'Picture'), + } + + +@employees_bp.route('/directory', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_directory_employee(): + guard = _require_selfhosted() + if guard: + return guard + fields = _employee_from_payload(request.get_json() or {}) + if not (fields['sso'] and fields['firstname'] and fields['lastname']): + return error_response(ErrorCodes.VALIDATION_ERROR, 'sso, firstname and lastname are required') + try: + sso = int(fields['sso']) + except (ValueError, TypeError): + return error_response(ErrorCodes.VALIDATION_ERROR, 'sso must be numeric') + if db.session.get(DirectoryEmployee, sso): + return error_response(ErrorCodes.CONFLICT, f'SSO {sso} already exists', http_code=409) + emp = DirectoryEmployee(sso=sso, firstname=fields['firstname'], lastname=fields['lastname'], + team=fields['team'], role=fields['role'], picture=fields['picture']) + db.session.add(emp) + db.session.commit() + return success_response(emp.to_dict(), message='Employee added', http_code=201) + + +@employees_bp.route('/directory/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_directory_employee(sso): + guard = _require_selfhosted() + if guard: + return guard + emp = db.session.get(DirectoryEmployee, sso) + if not emp: + return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404) + fields = _employee_from_payload(request.get_json() or {}) + if fields['firstname']: + emp.firstname = fields['firstname'] + if fields['lastname']: + emp.lastname = fields['lastname'] + for key in ('team', 'role', 'picture'): + if key in (request.get_json() or {}) or fields[key] is not None: + setattr(emp, key, fields[key]) + db.session.commit() + return success_response(emp.to_dict(), message='Employee updated') + + +@employees_bp.route('/directory/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_directory_employee(sso): + guard = _require_selfhosted() + if guard: + return guard + emp = db.session.get(DirectoryEmployee, sso) + if not emp: + return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404) + db.session.delete(emp) + db.session.commit() + return success_response(message='Employee removed') + + +@employees_bp.route('/directory/import', methods=['POST']) +@jwt_required() +@require_role('admin') +def import_directory(): + """Bulk upsert from CSV. Accepts headers SSO,First_Name,Last_Name,Team,Role, + Picture (case-insensitive; sso/firstname/... also accepted).""" + guard = _require_selfhosted() + if guard: + return guard + text = '' + if 'file' in request.files: + text = request.files['file'].read().decode('utf-8-sig', errors='replace') + else: + data = request.get_json(silent=True) or {} + text = data.get('csv', '') + if not text.strip(): + return error_response(ErrorCodes.VALIDATION_ERROR, 'No CSV provided') + + reader = csv.DictReader(io.StringIO(text)) + # Normalize headers to lower for tolerant matching. + added = updated = skipped = 0 + for raw in reader: + row = {(k or '').strip().lower(): (v or '').strip() for k, v in raw.items()} + sso_raw = row.get('sso') or row.get('sso ') + first = row.get('first_name') or row.get('firstname') + last = row.get('last_name') or row.get('lastname') + if not (sso_raw and sso_raw.isdigit() and first and last): + skipped += 1 + continue + sso = int(sso_raw) + team = row.get('team') or None + role = row.get('role') or None + picture = row.get('picture') or None + emp = db.session.get(DirectoryEmployee, sso) + if emp: + emp.firstname, emp.lastname, emp.team, emp.role, emp.picture = first, last, team, role, picture + updated += 1 + else: + db.session.add(DirectoryEmployee(sso=sso, firstname=first, lastname=last, + team=team, role=role, picture=picture)) + added += 1 + db.session.commit() + return success_response({'added': added, 'updated': updated, 'skipped': skipped}, + message=f'Import done: {added} added, {updated} updated, {skipped} skipped.') + + +# ============================================================================= +# Self-hosted employee photos (upload/replace/delete + public serve) +# ============================================================================= + +@employees_bp.route('//photo', methods=['POST']) +@jwt_required() +@require_role('admin') +def upload_employee_photo(sso): + """Upload (or replace) the photo for a self-hosted directory employee. + + multipart/form-data: file=. Saves to the instance employeephotos dir + as photo- (one photo per person) and points photofilename at it. + Re-upload replaces the old file even when the extension changes. External + mode is a 409 (photo is owned by the HR database).""" + guard = _require_selfhosted_photo() + if guard: + return guard + + emp = db.session.get(DirectoryEmployee, sso) + if not emp: + return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404) + + upload = request.files.get('file') + if not upload or not upload.filename: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') + ext = os.path.splitext(upload.filename)[1].lower() + if ext not in EMPLOYEE_PHOTO_EXTENSIONS: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Unsupported image type {ext}') + + photodir = _employeephoto_dir() + os.makedirs(photodir, exist_ok=True) + + # Wipe any prior photo-.* so a new extension does not orphan the old one. + for old in glob.glob(os.path.join(photodir, secure_filename(f'photo-{sso}') + '.*')): + os.remove(old) + + filename = secure_filename(f'photo-{sso}{ext}') + upload.save(os.path.join(photodir, filename)) + + emp.photofilename = filename + db.session.commit() + + return success_response(_with_photo_url(emp.to_dict()), message='Employee photo uploaded') + + +@employees_bp.route('/photo/', methods=['GET']) +def serve_employee_photo(filename): + """Serve an uploaded employee photo (public - kiosk cards read it).""" + return send_from_directory(_employeephoto_dir(), filename) + + +@employees_bp.route('//photo', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_employee_photo(sso): + """Clear an employee photo and delete the uploaded file. External mode 409s.""" + guard = _require_selfhosted_photo() + if guard: + return guard + + emp = db.session.get(DirectoryEmployee, sso) + if not emp: + return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404) + + if emp.photofilename: + # secure_filename strips any traversal; the file lives in our dir only. + path = os.path.join(_employeephoto_dir(), secure_filename(emp.photofilename)) + if os.path.exists(path): + os.remove(path) + + emp.photofilename = None + db.session.commit() + + return success_response(_with_photo_url(emp.to_dict()), message='Employee photo removed') diff --git a/plugins/employees/manifest.json b/plugins/employees/manifest.json new file mode 100644 index 00000000..0460d2bc --- /dev/null +++ b/plugins/employees/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "employees", + "version": "1.0.0", + "description": "Read-only employee directory lookup (separate HR database)", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/employees", + "provides": { + "features": [ + "employee_directory" + ] + }, + "default_enabled": false +} \ No newline at end of file diff --git a/plugins/employees/migrations/env.py b/plugins/employees/migrations/env.py new file mode 100644 index 00000000..548c662c --- /dev/null +++ b/plugins/employees/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the employees 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_employees. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'employees' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/employees/migrations/script.py.mako b/plugins/employees/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/employees/migrations/script.py.mako @@ -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"} diff --git a/plugins/employees/migrations/versions/0001_employees_anchor.py b/plugins/employees/migrations/versions/0001_employees_anchor.py new file mode 100644 index 00000000..bfd6ebcd --- /dev/null +++ b/plugins/employees/migrations/versions/0001_employees_anchor.py @@ -0,0 +1,28 @@ +"""employees 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_employees. From this +anchor forward, new employees 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 = 'employees0001anchor' +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 diff --git a/plugins/employees/migrations/versions/0002_employee_photofilename.py b/plugins/employees/migrations/versions/0002_employee_photofilename.py new file mode 100644 index 00000000..6aad6557 --- /dev/null +++ b/plugins/employees/migrations/versions/0002_employee_photofilename.py @@ -0,0 +1,40 @@ +"""Add photofilename to directoryemployees (self-hosted employee photos). + +The core chain (7d16_directoryemployees) created directoryemployees WITHOUT a +photofilename column. This plugin revision adds it, so BOTH fresh installs (core +chain builds the table, then this adds the column) and existing installs get it. +Guarded/idempotent: skips when the table is absent (plugin disabled) or the +column already exists (e.g. a test DB built by db.create_all() from the model). + +Revision ID: employees0002photo +Revises: employees0001anchor +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'employees0002photo' +down_revision = 'employees0001anchor' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'directoryemployees' not in insp.get_table_names(): + return + cols = {c['name'] for c in insp.get_columns('directoryemployees')} + if 'photofilename' not in cols: + op.add_column('directoryemployees', + sa.Column('photofilename', sa.String(length=255), nullable=True)) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'directoryemployees' not in insp.get_table_names(): + return + cols = {c['name'] for c in insp.get_columns('directoryemployees')} + if 'photofilename' in cols: + op.drop_column('directoryemployees', 'photofilename') diff --git a/plugins/employees/models/__init__.py b/plugins/employees/models/__init__.py new file mode 100644 index 00000000..25343b4d --- /dev/null +++ b/plugins/employees/models/__init__.py @@ -0,0 +1,5 @@ +"""Employees plugin models.""" + +from .directory_employee import DirectoryEmployee + +__all__ = ['DirectoryEmployee'] diff --git a/plugins/employees/models/directory_employee.py b/plugins/employees/models/directory_employee.py new file mode 100644 index 00000000..865d96fc --- /dev/null +++ b/plugins/employees/models/directory_employee.py @@ -0,0 +1,43 @@ +"""Self-hosted employee directory. + +For sites with no external HR database. When employee_directory_mode is +'selfhosted', the employee lookup APIs read this app-owned table instead of the +external directory, and the directory is managed in-app (CRUD + CSV import). + +to_dict emits the same keys the external contract returns (SSO, First_Name, +Last_Name, Team, Role, Picture) so the frontend and both modes share one shape. + +photofilename holds the basename of an uploaded photo (photo-) served +from instance/employeephotos/. It is distinct from the legacy Picture text +field: in self-hosted mode the displayed photo comes from uploads (photofilename) +via the shared resolver, not from Picture. +""" + +from shopdb.api import db + + +class DirectoryEmployee(db.Model): + __tablename__ = 'directoryemployees' + + sso = db.Column(db.Integer, primary_key=True, autoincrement=False) + firstname = db.Column(db.String(100), nullable=False) + lastname = db.Column(db.String(100), nullable=False) + team = db.Column(db.String(100)) + role = db.Column(db.String(100)) + picture = db.Column(db.String(255)) + # basename of an uploaded photo (photo-); None when no upload + photofilename = db.Column(db.String(255)) + + def to_dict(self): + # Keys match the external employees contract the frontend consumes. + # photofilename is extra (self-hosted upload); the resolved display URL + # is added as photourl by the API layer via resolve_employee_photo_url. + return { + 'SSO': self.sso, + 'First_Name': self.firstname, + 'Last_Name': self.lastname, + 'Team': self.team, + 'Role': self.role, + 'Picture': self.picture, + 'photofilename': self.photofilename, + } diff --git a/plugins/employees/plugin.py b/plugins/employees/plugin.py new file mode 100644 index 00000000..5f0a35bd --- /dev/null +++ b/plugins/employees/plugin.py @@ -0,0 +1,98 @@ +"""Employees plugin main class. + +Blueprint-only plugin: a read-only employee directory lookup over a separate HR +database. No model (the directory is an external DB) and no AssetType. The +connection helper (employee_connection) stays core infrastructure in shopdb.api, +shared with search and the notifications shopfloor feed. +""" + +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 employees_bp +from .models import DirectoryEmployee + +logger = logging.getLogger(__name__) + + +class EmployeesPlugin(BasePlugin): + """Employees plugin - read-only HR directory lookup.""" + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + 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 plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'employees'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Read-only employee directory lookup' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/employees'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return employees_bp + + def get_models(self) -> List[Type]: + """Self-hosted directory table (used when directory_mode=selfhosted). + External mode reads a separate DB via employee_connection instead.""" + return [DirectoryEmployee] + + def get_provisioning_note(self) -> Optional[Dict]: + return { + 'mode_setting': 'employee_directory_mode', + 'tables': ['directoryemployees'], + 'note': ('Enabling this in self-hosted mode creates a ' + '"directoryemployees" table in the shopdb database (SSO, ' + 'First_Name, Last_Name, Team, Role, Picture). It is ' + 'referenced across the site: employee search, notification ' + 'recognition (name + photo on the shopfloor/lobby displays), ' + 'and USB check-in/out name resolution. Manage people under ' + 'Settings > Employee Directory, or point at an existing HR ' + 'database instead (external mode).'), + 'docs': 'plugins/employees/README.md', + } + + def get_config_schema(self) -> List[Dict]: + """Employee directory DB connection. Host/name/user are settings the + wizard can edit; the password stays in .env (emitted, not stored).""" + return [ + {'key': 'employee_db_host', 'label': 'Employee DB host', 'type': 'text', + 'secret': False, 'default': 'localhost', + 'help': 'This DB must expose an "employees" table or view with columns ' + 'SSO, First_Name, Last_Name, Team, Role, Picture. ' + 'See plugins/employees/README.md.'}, + {'key': 'employee_db_name', 'label': 'Employee DB name', 'type': 'text', + 'secret': False, 'default': 'wjf_employees'}, + {'key': 'employee_db_user', 'label': 'Employee DB user', 'type': 'text', + 'secret': False}, + {'key': 'employee_db_password', 'label': 'Employee DB password', 'type': 'password', + 'secret': True, 'envvar': 'EMPLOYEE_DB_PASSWORD', + 'help': 'Stored in .env, not the database. The wizard shows the line to paste.'}, + ] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Employees plugin initialized (v{self.meta.version})") diff --git a/plugins/geenforce/__init__.py b/plugins/geenforce/__init__.py new file mode 100644 index 00000000..8798ba98 --- /dev/null +++ b/plugins/geenforce/__init__.py @@ -0,0 +1,5 @@ +"""GE-Enforce manifest-store plugin package.""" + +from .plugin import GeEnforcePlugin + +__all__ = ['GeEnforcePlugin'] diff --git a/plugins/geenforce/api/__init__.py b/plugins/geenforce/api/__init__.py new file mode 100644 index 00000000..34c47e78 --- /dev/null +++ b/plugins/geenforce/api/__init__.py @@ -0,0 +1,5 @@ +"""GE-Enforce plugin API blueprint.""" + +from .routes import geenforce_bp + +__all__ = ['geenforce_bp'] diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py new file mode 100644 index 00000000..c93d34e7 --- /dev/null +++ b/plugins/geenforce/api/routes.py @@ -0,0 +1,712 @@ +"""GE-Enforce plugin API. + +Two audiences: +- Admin (JWT + geenforce.manage): browse scopes and preview the draft manifest. + Full CRUD + publish lands in P2; this is the P1/first-slice read surface. +- Client (service token, geenforce.fetch scope): GET /manifest serves the + CURRENT PUBLISHED snapshot for a scope, never the live draft. Auth mirrors the + collector's managed-token pattern (X-API-Key or Bearer PAT). +""" + +from functools import wraps + +from flask import Blueprint, request, Response +from flask_jwt_extended import jwt_required +from sqlalchemy.exc import IntegrityError + +from shopdb.api import ( + db, success_response, error_response, ErrorCodes, require_permission, + service_token_authorized, Setting, Application, +) + +SHAREROOT_SETTING = 'geenforce_share_root' + +from ..models import ( + ManifestScope, ManifestEntry, ManifestPublishedVersion, + ManifestEnforcementReport, ManifestPayload, ENTRY_TYPES, PHASES, +) +from ..serializer import scope_to_manifest, entry_to_dict +from ..importer import build_entry, populate_entry +from ..filters import ( + entry_applies, matches_pctype, matches_hostname, matches_machinenumber, + matches_cmmversion, +) +from .. import service + +geenforce_bp = Blueprint('geenforce', __name__) + +FETCH_SCOPE = 'geenforce.fetch' +REPORT_SCOPE = 'geenforce.report' + + +def _require_service_token(scope): + """Decorator factory: require a managed service token scoped for `scope`. + + Tokens are the only client auth path (the client kit provisions a + geenforce.fetch/report token). No env-key fallback: it was never wired into + config and an unscoped shared key is a needless backdoor. + """ + def wrapper(f): + @wraps(f) + def decorated(*args, **kwargs): + if service_token_authorized(scope): + return f(*args, **kwargs) + return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key', + http_code=401) + return decorated + return wrapper + + +require_fetch_token = _require_service_token(FETCH_SCOPE) +require_report_token = _require_service_token(REPORT_SCOPE) + + +# -- client-facing endpoint --------------------------------------------------- + +@geenforce_bp.route('/manifest', methods=['GET']) +@require_fetch_token +def get_manifest(): + """Serve the current published manifest for a scope (fat-client: full scope). + + Query: pctype (=scopename, required), phase (default runtime). The engine + filters client-side, matching today, so subtype/hostname/machinenumber/ + cmmversion are accepted but not applied here. + """ + scopename = (request.args.get('pctype') or '').strip() + phase = (request.args.get('phase') or 'runtime').strip() + if not scopename: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'pctype is required', http_code=400) + + scope = ManifestScope.query.filter_by( + scopename=scopename, phase=phase).first() + if not scope: + return error_response(ErrorCodes.NOT_FOUND, + f'No scope: {scopename}', http_code=404) + + published = scope.publishedversions.filter_by(iscurrent=True).first() + if not published: + return error_response(ErrorCodes.NOT_FOUND, + f'{scopename} has no published version', + http_code=404) + + etag = f'"{scope.scopeid}-v{published.versionnumber}"' + if request.headers.get('If-None-Match') == etag: + return Response(status=304, headers={'ETag': etag}) + return Response(published.manifestjson, mimetype='application/json', + headers={'ETag': etag, + 'X-Manifest-Version': str(published.versionnumber)}) + + +# -- client reporting (observed state) ---------------------------------------- + +@geenforce_bp.route('/report', methods=['POST']) +@require_report_token +def post_report(): + """Record one PC's enforcement cycle: applied version (did it receive the + update?) + per-entry self-heal outcomes (installed/skipped/failed).""" + payload = request.get_json(silent=True) or {} + if not (payload.get('hostname') or '').strip(): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'hostname is required', http_code=400) + try: + report = service.record_enforcement_report(payload) + db.session.commit() + except ValueError as exc: + db.session.rollback() + return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), + http_code=400) + return success_response({'reportid': report.reportid, + 'status': report.status}, message='recorded') + + +# -- admin read surface (P2 adds full CRUD + publish) ------------------------- + +@geenforce_bp.route('/scopes', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def list_scopes(): + """List imaging PC-type scopes with entry + published-version counts.""" + scopes = ManifestScope.query.order_by( + ManifestScope.phase, ManifestScope.scopename).all() + data = [] + for scope in scopes: + current = scope.publishedversions.filter_by(iscurrent=True).first() + data.append({ + 'scopeid': scope.scopeid, + 'scopename': scope.scopename, + 'phase': scope.phase, + 'manifestversion': scope.manifestversion, + 'computertypeid': scope.computertypeid, + 'measuringtooltypeid': scope.measuringtooltypeid, + 'iscommon': scope.iscommon, + 'entrycount': len(scope.entries), + 'publishedversion': current.versionnumber if current else None, + }) + return success_response(data) + + +@geenforce_bp.route('/scopes//preview', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def preview_scope(scopeid): + """Render the DRAFT manifest JSON a publish would freeze (review before ship).""" + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', + http_code=404) + return success_response({'scopename': scope.scopename, + 'manifest': scope_to_manifest(scope)}) + + +@geenforce_bp.route('/applications', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def list_applications(): + """The core Applications catalog (id + name) for the curated entry->app link + picker in the entry editor.""" + apps = Application.query.filter_by(isactive=True).order_by( + Application.appname).all() + return success_response([{'appid': a.appid, 'appname': a.appname} + for a in apps]) + + +# -- scope CRUD (geenforce.manage) -------------------------------------------- + +def _scope_summary(scope): + current = scope.publishedversions.filter_by(iscurrent=True).first() + return { + 'scopeid': scope.scopeid, + 'scopename': scope.scopename, + 'phase': scope.phase, + 'manifestversion': scope.manifestversion, + 'description': scope.description, + 'computertypeid': scope.computertypeid, + 'measuringtooltypeid': scope.measuringtooltypeid, + 'iscommon': scope.iscommon, + 'entrycount': len(scope.entries), + 'publishedversion': current.versionnumber if current else None, + } + + +def _entry_payload(entry): + """Manifest-entry dict with entryid + sortorder + the curated app link. + + appid/appname are shopdb metadata (not manifest fields), added alongside the + rendered manifest entry for the editor. + """ + data = {'entryid': entry.entryid, 'sortorder': entry.sortorder, + 'appid': entry.appid, 'appname': None} + if entry.appid: + app = db.session.get(Application, entry.appid) + data['appname'] = app.appname if app else None + data.update(entry_to_dict(entry)) + # Inline-payload metadata (shopdb-only, NOT manifest keys) for the editor. + data['payloadsource'] = entry.payloadsource + data['payloadref'] = entry.payloadref + data['payloadsha256'] = entry.payloadsha256 + data['haspayload'] = (entry.payloadsource == 'inline' + and entry.payloadsha256 is not None) + return data + + +def _apply_app_link(entry, payload): + """Set the optional curated appid from the payload (shopdb metadata, not a + manifest field, so handled outside populate_entry). Ignores an unknown id.""" + if 'appid' not in payload: + return + appid = payload.get('appid') + if appid in (None, '', 0): + entry.appid = None + return + # Ignore a non-numeric id rather than 500 (matches the docstring contract). + try: + appid = int(appid) + except (ValueError, TypeError): + return + if db.session.get(Application, appid): + entry.appid = appid + + +@geenforce_bp.route('/scopes', methods=['POST']) +@jwt_required() +@require_permission('geenforce.manage') +def create_scope(): + payload = request.get_json(silent=True) or {} + scopename = (payload.get('scopename') or '').strip() + phase = (payload.get('phase') or 'runtime').strip() + if not scopename: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'scopename is required', http_code=400) + if phase not in PHASES: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'phase must be one of {PHASES}', http_code=400) + if ManifestScope.query.filter_by(scopename=scopename, phase=phase).first(): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'scope already exists', http_code=400) + scope = ManifestScope( + scopename=scopename, phase=phase, + manifestversion=str(payload.get('manifestversion', '1.0')), + description=payload.get('description'), + computertypeid=payload.get('computertypeid'), + measuringtooltypeid=payload.get('measuringtooltypeid'), + iscommon=bool(payload.get('iscommon', scopename == 'common'))) + db.session.add(scope) + db.session.commit() + return success_response(_scope_summary(scope), http_code=201) + + +@geenforce_bp.route('/scopes/', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def get_scope(scopeid): + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + data = _scope_summary(scope) + data['entries'] = [_entry_payload(e) for e in scope.entries] + return success_response(data) + + +@geenforce_bp.route('/scopes/', methods=['PUT']) +@jwt_required() +@require_permission('geenforce.manage') +def update_scope(scopeid): + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + payload = request.get_json(silent=True) or {} + for field in ('description', 'computertypeid', 'measuringtooltypeid'): + if field in payload: + setattr(scope, field, payload[field]) + if 'manifestversion' in payload: + scope.manifestversion = str(payload['manifestversion']) + if 'iscommon' in payload: + scope.iscommon = bool(payload['iscommon']) + db.session.commit() + return success_response(_scope_summary(scope)) + + +@geenforce_bp.route('/scopes/', methods=['DELETE']) +@jwt_required() +@require_permission('geenforce.manage') +def delete_scope(scopeid): + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + db.session.delete(scope) + db.session.commit() + return success_response({'deleted': scopeid}) + + +# -- entry CRUD (geenforce.manage) -------------------------------------------- + +def _validate_entry(payload): + """Return an error string, or None if the entry payload is valid.""" + if not (payload.get('Name') or '').strip(): + return 'entry Name is required' + if payload.get('Type') not in ENTRY_TYPES: + return f'entry Type must be one of {ENTRY_TYPES}' + return None + + +@geenforce_bp.route('/scopes//entries', methods=['POST']) +@jwt_required() +@require_permission('geenforce.manage') +def create_entry(scopeid): + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + payload = request.get_json(silent=True) or {} + invalid = _validate_entry(payload) + if invalid: + return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400) + nextorder = max([e.sortorder for e in scope.entries], default=-1) + 1 + entry = build_entry(payload, nextorder) + _apply_app_link(entry, payload) + scope.entries.append(entry) + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + return error_response(ErrorCodes.VALIDATION_ERROR, + 'an entry with that Name already exists in this scope', + http_code=400) + return success_response(_entry_payload(entry), http_code=201) + + +@geenforce_bp.route('/entries/', methods=['PUT']) +@jwt_required() +@require_permission('geenforce.manage') +def update_entry(entryid): + entry = db.session.get(ManifestEntry, entryid) + if not entry: + return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404) + payload = request.get_json(silent=True) or {} + invalid = _validate_entry(payload) + if invalid: + return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400) + # Free the one-to-one InUseCheck (unique entryid) before populate re-inserts + # it, so the replacement does not collide with the old row mid-flush. + if entry.inusecheck is not None: + db.session.delete(entry.inusecheck) + entry.inusecheck = None + db.session.flush() + populate_entry(entry, payload) + _apply_app_link(entry, payload) + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + return error_response(ErrorCodes.VALIDATION_ERROR, + 'an entry with that Name already exists in this scope', + http_code=400) + return success_response(_entry_payload(entry)) + + +@geenforce_bp.route('/entries/', methods=['DELETE']) +@jwt_required() +@require_permission('geenforce.manage') +def delete_entry(entryid): + entry = db.session.get(ManifestEntry, entryid) + if not entry: + return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404) + db.session.delete(entry) + db.session.commit() + return success_response({'deleted': entryid}) + + +@geenforce_bp.route('/scopes//entries/reorder', methods=['PUT']) +@jwt_required() +@require_permission('geenforce.manage') +def reorder_entries(scopeid): + """Set entry order from a list of entryids (the ordering contract).""" + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + order = (request.get_json(silent=True) or {}).get('order') or [] + owned = {e.entryid: e for e in scope.entries} + if set(order) != set(owned): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'order must list exactly this scope\'s entry ids', + http_code=400) + for position, entryid in enumerate(order): + owned[entryid].sortorder = position + db.session.commit() + return success_response({'order': order}) + + +# -- simulate: "what would this PC get" --------------------------------------- + +@geenforce_bp.route('/scopes//simulate', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def simulate_scope(scopeid): + """Which draft entries apply to a given machine profile, and why the rest + are filtered out (reuses the engine-mirror filters).""" + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + profile = { + 'pctype': request.args.get('pctype') or scope.scopename, + 'subtype': request.args.get('subtype'), + 'hostname': request.args.get('hostname'), + 'machinenumber': request.args.get('machinenumber'), + 'cmmversion': request.args.get('cmmversion'), + 'phase': scope.phase, + } + applied, filtered = [], [] + for entry in scope.entries: + entry_dict = entry_to_dict(entry) + if entry_applies(entry_dict, profile): + applied.append(entry.name) + else: + reasons = [] + strict_allowed = profile.get('phase') == 'preinstall' + if not matches_pctype(entry_dict, profile['pctype'], + profile['subtype'], strict_allowed): + reasons.append('PCTypes') + if not matches_hostname(entry_dict, profile['hostname']): + reasons.append('TargetHostnames') + if not matches_machinenumber(entry_dict, profile['machinenumber']): + reasons.append('TargetMachineNumbers') + if not matches_cmmversion(entry_dict, profile['cmmversion']): + reasons.append('_CmmVersion') + filtered.append({'name': entry.name, 'filteredby': reasons}) + return success_response({'profile': profile, 'applied': applied, + 'filtered': filtered}) + + +# -- compliance: "how much of the fleet has this app" ------------------------- + +@geenforce_bp.route('/scopes//compliance', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def scope_compliance(scopeid): + """Fleet-install coverage per app-linked entry (from collected PC data). + + One row per entry that carries a curated appid; installed/version-match + counts come from the computers plugin's ComputerInstalledApp. Degrades to + null counts (computersplugin: false) when that plugin is absent. + """ + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', + http_code=404) + return success_response(service.compliance_for_scope(scope)) + + +# -- inline payload upload / fetch (small scripts + configs) ------------------ + +PAYLOAD_MAX_BYTES = 1024 * 1024 + + +@geenforce_bp.route('/entries//payload', methods=['POST']) +@jwt_required() +@require_permission('geenforce.publish') +def upload_entry_payload(entryid): + """Store an inline payload (<= 1 MB) for an entry and point the entry at it.""" + entry = db.session.get(ManifestEntry, entryid) + if not entry: + return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404) + uploaded = request.files.get('file') + if uploaded is None: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'a file is required', http_code=400) + rawbytes = uploaded.read() + if not rawbytes: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'payload is empty', http_code=400) + if len(rawbytes) > PAYLOAD_MAX_BYTES: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'payload exceeds 1 MB limit', http_code=400) + service.store_inline_payload(entry, uploaded.filename, + uploaded.mimetype, rawbytes) + db.session.commit() + return success_response(_entry_payload(entry), http_code=201) + + +@geenforce_bp.route('/entries//payload', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def download_entry_payload(entryid): + """Return the stored inline payload bytes for an entry (404 if none).""" + entry = db.session.get(ManifestEntry, entryid) + if not entry: + return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404) + payload = ManifestPayload.query.filter_by(entryid=entryid).first() + if not payload: + return error_response(ErrorCodes.NOT_FOUND, 'entry has no payload', + http_code=404) + return Response( + payload.payloadbytes, + mimetype=payload.contenttype or 'application/octet-stream', + headers={'Content-Disposition': + f'attachment; filename="{payload.filename}"'}) + + +# -- publish lifecycle (geenforce.publish) ------------------------------------ + +@geenforce_bp.route('/scopes//publish', methods=['POST']) +@jwt_required() +@require_permission('geenforce.publish') +def publish_scope_route(scopeid): + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + from flask_jwt_extended import get_jwt_identity + notes = (request.get_json(silent=True) or {}).get('notes') + try: + publishedby = int(get_jwt_identity()) + except (TypeError, ValueError): + publishedby = None + version = service.publish_scope(scope.scopename, scope.phase, + notes=notes, publishedby=publishedby) + db.session.commit() + return success_response({'versionnumber': version}, http_code=201) + + +@geenforce_bp.route('/scopes//versions', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def list_versions(scopeid): + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + versions = ManifestPublishedVersion.query.filter_by( + scopeid=scopeid).order_by( + ManifestPublishedVersion.versionnumber.desc()).all() + return success_response([{ + 'versionnumber': v.versionnumber, + 'iscurrent': v.iscurrent, + 'publishedat': v.publishedat.isoformat() + 'Z' if v.publishedat else None, + 'publishedby': v.publishedby, + 'notes': v.notes, + } for v in versions]) + + +@geenforce_bp.route('/scopes//versions/', + methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def get_version(scopeid, versionnumber): + version = ManifestPublishedVersion.query.filter_by( + scopeid=scopeid, versionnumber=versionnumber).first() + if not version: + return error_response(ErrorCodes.NOT_FOUND, 'No such version', + http_code=404) + import json as _json + return success_response({'versionnumber': version.versionnumber, + 'manifest': _json.loads(version.manifestjson)}) + + +@geenforce_bp.route('/config', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def get_config(): + """Plugin config: the on-share export root (for export-to-share).""" + setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first() + return success_response({'shareroot': setting.value if setting else ''}) + + +@geenforce_bp.route('/config', methods=['PUT']) +@jwt_required() +@require_permission('geenforce.publish') +def put_config(): + payload = request.get_json(silent=True) or {} + Setting.set(SHAREROOT_SETTING, (payload.get('shareroot') or '').strip(), + valuetype='string', category='geenforce', + description='On-share export root for GE-Enforce manifests') + db.session.commit() + return success_response({'shareroot': (payload.get('shareroot') or '').strip()}) + + +@geenforce_bp.route('/scopes//export-share', methods=['POST']) +@jwt_required() +@require_permission('geenforce.publish') +def export_share_route(scopeid): + """Write the scope's current published JSON to the configured share root + (backing up the old file to _meta/history first).""" + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first() + shareroot = setting.value if setting else '' + if not shareroot: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'Configure the share root first', http_code=400) + try: + path = service.export_scope_to_share(scope.scopename, scope.phase, + shareroot) + except (ValueError, OSError) as exc: + return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400) + return success_response({'path': path}) + + +@geenforce_bp.route('/scopes//rollback', methods=['POST']) +@jwt_required() +@require_permission('geenforce.publish') +def rollback_scope_route(scopeid): + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + versionnumber = (request.get_json(silent=True) or {}).get('versionnumber') + if versionnumber is None: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'versionnumber is required', http_code=400) + try: + service.rollback_scope(scope.scopename, scope.phase, int(versionnumber)) + db.session.commit() + except ValueError as exc: + db.session.rollback() + return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400) + return success_response({'versionnumber': int(versionnumber)}) + + +def _current_published_version(scopename, phase): + scope = ManifestScope.query.filter_by( + scopename=scopename, phase=phase).first() + if not scope: + return None + published = scope.publishedversions.filter_by(iscurrent=True).first() + return published.versionnumber if published else None + + +@geenforce_bp.route('/reports', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def list_reports(): + """Latest enforcement report per PC (fleet compliance view). + + Each row shows the applied vs latest published version (receivedlatest = the + PC picked up the update) and the install/skip/fail counts + status. + Optional filters: hostname, scopename. + """ + query = ManifestEnforcementReport.query.filter_by(iscurrent=True) + hostname = request.args.get('hostname') + scopename = request.args.get('scopename') + if hostname: + query = query.filter(ManifestEnforcementReport.hostname.ilike(hostname)) + if scopename: + query = query.filter_by(scopename=scopename) + reports = query.order_by( + ManifestEnforcementReport.receivedat.desc()).all() + + latest_cache = {} + data = [] + for report in reports: + key = (report.scopename, report.phase) + if key not in latest_cache: + latest_cache[key] = _current_published_version(*key) + latest = latest_cache[key] + data.append({ + 'reportid': report.reportid, + 'hostname': report.hostname, + 'scopename': report.scopename, + 'phase': report.phase, + 'appliedversion': report.appliedversion, + 'latestversion': latest, + 'receivedlatest': (latest is not None + and report.appliedversion == latest), + 'enforcerversion': report.enforcerversion, + 'status': report.status, + 'installed': report.installedcount, + 'skipped': report.skippedcount, + 'failed': report.failedcount, + 'filtered': report.filteredcount, + 'lastcheckin': (report.lastcheckin.isoformat() + 'Z' + if report.lastcheckin else None), + 'receivedat': (report.receivedat.isoformat() + 'Z' + if report.receivedat else None), + }) + return success_response(data) + + +@geenforce_bp.route('/reports/', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def get_report(reportid): + """One report with per-entry outcomes (self-heal / failure detail).""" + report = db.session.get(ManifestEnforcementReport, reportid) + if not report: + return error_response(ErrorCodes.NOT_FOUND, 'No such report', + http_code=404) + latest = _current_published_version(report.scopename, report.phase) + return success_response({ + 'reportid': report.reportid, + 'hostname': report.hostname, + 'scopename': report.scopename, + 'phase': report.phase, + 'appliedversion': report.appliedversion, + 'latestversion': latest, + 'receivedlatest': (latest is not None + and report.appliedversion == latest), + 'status': report.status, + 'results': [{ + 'entryname': r.entryname, + 'action': r.action, + 'selfhealed': r.selfhealed, + 'exitcode': r.exitcode, + 'message': r.message, + } for r in report.results], + }) diff --git a/plugins/geenforce/client/Install-GEEnforce.ps1 b/plugins/geenforce/client/Install-GEEnforce.ps1 new file mode 100644 index 00000000..2f4b6e66 --- /dev/null +++ b/plugins/geenforce/client/Install-GEEnforce.ps1 @@ -0,0 +1,180 @@ +<# +.SYNOPSIS + Provision a PC for GE-Enforce + shopdb: write its identity (so it knows its PC + type and bay), deploy the shopdb client kit, and register the enforcement + scheduled task. Site-neutral and imaging-path independent - run it from a PXE + step, an OOBE provisioning package (ppkg), Intune, or by hand. + + This is the "set up the PC to determine its PC type and such" step. There is no + auto-detection: the caller supplies the type (and, where relevant, the bay + machine number and CMM version), and this script writes the files/registry the + GE-Enforce engine reads. + + This script does NOT contain the GE-Enforce engine (Install-FromManifest.ps1) + or dispatcher - those are the GE-Enforce framework's. Point at your copy with + -EngineSource, or ensure it is already present under -InstallRoot\lib. + +.PARAMETER PCType + The imaging PC type = the manifest scope this PC runs. One of the scope names + (e.g. gea-shopfloor-cmm, gea-shopfloor-collections) or a legacy alias the + engine maps. Written to C:\Enrollment\pc-type.txt. REQUIRED. + +.PARAMETER MachineNumber + The bay machine number (for TargetMachineNumbers gates). Written to + machine-number.txt as a fallback; the DNC registry MachineNo wins if present. + +.PARAMETER CmmVersion + CMM bays only: the resolved PC-DMIS version (2016/2019/2026) for _CmmVersion + gating. Written to C:\Enrollment\cmm\version.txt. Omit for non-CMM PCs. + +.PARAMETER CmmId + CMM bays only: the bay id. Written to C:\Enrollment\cmm\cmmid.txt. + +.PARAMETER ShareRoot + The GE-Enforce share root (UNC), written into site-config.json as + shopfloorShareRoot. + +.PARAMETER Site + Site name, written into site-config.json. + +.PARAMETER ShopdbUrl + shopdb base URL (scheme + host) for the fetch/report client. Written to + HKLM:\SOFTWARE\GE\ShopDB\BaseUrl. + +.PARAMETER ShopdbToken + A geenforce.fetch (+ geenforce.report) managed service token. Written to + HKLM:\SOFTWARE\GE\ShopDB\ApiToken. (In many sites Azure DSC provisions this + instead - omit here if so.) + +.PARAMETER EngineSource + Optional path (folder or share) containing the GE-Enforce engine to copy in: + expects GE-Enforce.ps1 and lib\Install-FromManifest.ps1. If omitted, the engine + is assumed already present under -InstallRoot. + +.PARAMETER InstallRoot + Where the client kit + engine live on the PC. Default C:\ProgramData\GE-Enforce. + +.PARAMETER TaskName + Scheduled task name. Default 'GE-Enforce'. + +.PARAMETER IntervalMinutes + How often the enforcement task repeats. Default 5. + +.PARAMETER NoTask + Provision identity + kit only; do not register the scheduled task. + +.EXAMPLE + .\Install-GEEnforce.ps1 -PCType gea-shopfloor-cmm -MachineNumber 0615 ` + -CmmVersion 2019 -ShareRoot \\server\share\dt\shopfloor -Site "West Jefferson" ` + -ShopdbUrl https://shopdb.site.geaerospace.net -ShopdbToken shopdb_pat_xxx ` + -EngineSource \\server\share\dt\shopfloor\common +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string]$PCType, + [string]$MachineNumber, + [string]$CmmVersion, + [string]$CmmId, + [string]$ShareRoot, + [string]$Site, + [string]$ShopdbUrl, + [string]$ShopdbToken, + [string]$EngineSource, + [string]$InstallRoot = 'C:\ProgramData\GE-Enforce', + [string]$EnrollmentRoot = 'C:\Enrollment', + [string]$TaskName = 'GE-Enforce', + [int]$IntervalMinutes = 5, + [switch]$NoTask +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-TextFile { + param([string]$Path, [string]$Value) + $dir = Split-Path -Parent $Path + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + Set-Content -LiteralPath $Path -Value $Value -Encoding ascii -NoNewline + Write-Host " wrote $Path" +} + +try { + Write-Host "GE-Enforce provisioning: PCType=$PCType" + + # --- 1. Identity: what the PC is (what the engine reads) ----------------- + Write-TextFile (Join-Path $EnrollmentRoot 'pc-type.txt') $PCType + if ($MachineNumber) { Write-TextFile (Join-Path $EnrollmentRoot 'machine-number.txt') $MachineNumber } + if ($CmmVersion) { Write-TextFile (Join-Path $EnrollmentRoot 'cmm\version.txt') $CmmVersion } + if ($CmmId) { Write-TextFile (Join-Path $EnrollmentRoot 'cmm\cmmid.txt') $CmmId } + + $siteConfig = @{} + if ($ShareRoot) { $siteConfig['shopfloorShareRoot'] = $ShareRoot } + if ($Site) { $siteConfig['site'] = $Site } + if ($siteConfig.Count) { + $siteConfigPath = Join-Path $EnrollmentRoot 'site-config.json' + $dir = Split-Path -Parent $siteConfigPath + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + ($siteConfig | ConvertTo-Json) | Set-Content -LiteralPath $siteConfigPath -Encoding ascii + Write-Host " wrote $siteConfigPath" + } + + # --- 2. shopdb client registry config ------------------------------------ + if ($ShopdbUrl -or $ShopdbToken) { + $regPath = 'HKLM:\SOFTWARE\GE\ShopDB' + if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null } + if ($ShopdbUrl) { Set-ItemProperty -Path $regPath -Name BaseUrl -Value $ShopdbUrl } + if ($ShopdbToken) { Set-ItemProperty -Path $regPath -Name ApiToken -Value $ShopdbToken } + Write-Host " wrote HKLM:\SOFTWARE\GE\ShopDB" + } + + # --- 3. Deploy the client kit (ships alongside this script) -------------- + if (-not (Test-Path $InstallRoot)) { New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null } + foreach ($file in 'ShopdbEnforceClient.psm1', 'Invoke-ShopdbEnforce.ps1') { + $src = Join-Path $PSScriptRoot $file + if (Test-Path $src) { + Copy-Item -Path $src -Destination (Join-Path $InstallRoot $file) -Force + Write-Host " deployed $file" + } else { + Write-Warning "client kit file not found next to this script: $file" + } + } + + # --- 4. Engine (referenced, not vendored) -------------------------------- + if ($EngineSource) { + Copy-Item -Path (Join-Path $EngineSource 'GE-Enforce.ps1') ` + -Destination (Join-Path $InstallRoot 'GE-Enforce.ps1') -Force -ErrorAction SilentlyContinue + $libDir = Join-Path $InstallRoot 'lib' + if (-not (Test-Path $libDir)) { New-Item -ItemType Directory -Path $libDir -Force | Out-Null } + Copy-Item -Path (Join-Path $EngineSource 'lib\Install-FromManifest.ps1') ` + -Destination (Join-Path $libDir 'Install-FromManifest.ps1') -Force -ErrorAction SilentlyContinue + Write-Host " copied engine from $EngineSource" + } + $enginePath = Join-Path $InstallRoot 'lib\Install-FromManifest.ps1' + if (-not (Test-Path $enginePath)) { + Write-Warning "engine not present at $enginePath - provide -EngineSource or place it there before enforcement runs." + } + + # --- 5. Scheduled task: run the enforcement client as SYSTEM ------------- + if (-not $NoTask) { + $runner = Join-Path $InstallRoot 'Invoke-ShopdbEnforce.ps1' + $arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$runner`" -Scope `"$PCType`" -EnginePath `"$enginePath`"" + $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments + $triggers = @( + (New-ScheduledTaskTrigger -AtLogOn), + (New-ScheduledTaskTrigger -Once -At (Get-Date) ` + -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)) + ) + $principal = New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries -StartWhenAvailable + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers ` + -Principal $principal -Settings $settings -Force | Out-Null + Write-Host " registered task '$TaskName' (at logon + every $IntervalMinutes min)" + } + + Write-Host "GE-Enforce provisioning complete." + exit 0 +} catch { + Write-Error "GE-Enforce provisioning FAILED: $($_.Exception.Message)" + exit 1 +} diff --git a/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 b/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 new file mode 100644 index 00000000..4128a821 --- /dev/null +++ b/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 @@ -0,0 +1,103 @@ +<# +.SYNOPSIS + Reference orchestrator: source a GE-Enforce manifest from shopdb, run the + UNCHANGED engine against it, and report the result back to shopdb. + + This is a thin wrapper around Install-FromManifest.ps1 (the engine). It is a + reference a site adapts into its GE-Enforce.ps1 flow; it is not the live + dispatcher. The engine, detection, self-heal, and SMB payload resolution are + untouched - only the source of the manifest JSON moves from a share file to + shopdb, plus a result report. + +.PARAMETER Scope + The imaging pc-type / scope name (e.g. gea-shopfloor-cmm), same value the + dispatcher already resolves from C:\Enrollment\pc-type.txt. + +.PARAMETER EnginePath + Path to Install-FromManifest.ps1 (the engine lib, >= 2.6). + +.PARAMETER ShareManifestPath + The current on-share manifest for this scope. In shadow mode the engine runs + against THIS (unchanged behavior) and shopdb is only compared + reported. Once + cut over, omit it and the engine runs against the shopdb-sourced manifest. + +.PARAMETER ShadowMode + Fetch + compare + report, but install from the share (no behavior change). + +.NOTES + Fail-safe: any error exits 0 so a bad web app never blocks or breaks a PC. + Config comes from HKLM:\SOFTWARE\GE\ShopDB (BaseUrl, ApiToken) - see the psm1. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string]$Scope, + [Parameter(Mandatory)] [string]$EnginePath, + [string]$ShareManifestPath, + [switch]$ShadowMode, + [string]$BaseUrl, + [string]$ApiToken, + [string]$LogFile = "C:\Logs\Shopfloor\shopdb-enforce-$(Get-Date -Format yyyyMMdd).log" +) + +function Write-Log { + param([string]$Message, [string]$Level = 'INFO') + $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message" + try { Add-Content -LiteralPath $LogFile -Value $line -ErrorAction SilentlyContinue } catch {} + Write-Host $line +} + +try { + Import-Module (Join-Path $PSScriptRoot 'ShopdbEnforceClient.psm1') -Force + + $config = Get-ShopdbConfig -BaseUrl $BaseUrl -ApiToken $ApiToken + if (-not $config) { + Write-Log 'No shopdb BaseUrl/ApiToken configured yet - retry next cycle.' 'WARN' + exit 0 + } + + $sync = Sync-ShopdbManifest -Scope $Scope -Config $config + if (-not $sync.Path) { + Write-Log "No manifest available for $Scope (shopdb unreachable, no cache)." 'WARN' + exit 0 + } + Write-Log "Manifest for $Scope from $($sync.Source) (v$($sync.Version))." + + # Shadow mode: compare shopdb vs the share, but install from the share. + if ($ShadowMode -and $ShareManifestPath -and (Test-Path $ShareManifestPath)) { + $diff = Compare-ShopdbShadow -ShopdbManifestPath $sync.Path -ShareManifestPath $ShareManifestPath + if ($diff.Same) { + Write-Log 'Shadow: shopdb manifest matches the share.' + } else { + Write-Log ("Shadow DIFF: shopdb-only=[{0}] share-only=[{1}] orderDiff={2}" -f ` + ($diff.ShopdbOnly -join ','), ($diff.ShareOnly -join ','), $diff.OrderDiff) 'WARN' + } + } + + # Which manifest the engine actually runs against. + $manifestToRun = if ($ShadowMode -and $ShareManifestPath) { $ShareManifestPath } else { $sync.Path } + + # --- INTEGRATION POINT --------------------------------------------------- + # Run the engine. Install-FromManifest.ps1 is expected to return (or you + # adapt it to return) a summary carrying Installed/Skipped/Failed/Filtered + # and a Results list of @{Name;Action;SelfHealed;ExitCode;Message}. Wire this + # to your engine's actual return/parse; the shape below is the contract. + Write-Log "Running engine against $manifestToRun" + $summary = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope + if (-not $summary) { + $summary = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0; Results = @() } + } + if (-not $summary.EnforcerVersion) { $summary.EnforcerVersion = '2.6' } + + # Report the result (best-effort). + $appliedVersion = 0 + if ($sync.Version) { [int]::TryParse($sync.Version, [ref]$appliedVersion) | Out-Null } + $report = New-ShopdbReport -Scope $Scope -AppliedVersion $appliedVersion -Summary $summary + if (Send-ShopdbReport -Config $config -Report $report) { + Write-Log "Reported: installed=$($summary.Installed) skipped=$($summary.Skipped) failed=$($summary.Failed)." + } else { + Write-Log 'Report POST failed (non-fatal).' 'WARN' + } +} catch { + Write-Log "Unhandled error (non-fatal): $($_.Exception.Message)" 'ERROR' +} +exit 0 diff --git a/plugins/geenforce/client/ShopdbEnforceClient.psm1 b/plugins/geenforce/client/ShopdbEnforceClient.psm1 new file mode 100644 index 00000000..a84d2d56 --- /dev/null +++ b/plugins/geenforce/client/ShopdbEnforceClient.psm1 @@ -0,0 +1,164 @@ +<# +.SYNOPSIS + Client-side helpers for sourcing GE-Enforce manifests from shopdb and reporting + results back. Site-neutral reference kit - deploy alongside GE-Enforce; do NOT + hardcode any site here. + + This module does NOT replace Install-FromManifest.ps1 (the engine). It only + changes where the manifest JSON comes from (shopdb HTTP instead of a share + file) and adds a result report. The engine, detection, self-heal, and SMB + payload resolution stay exactly as they are. + + Resilience mirrors GE-Enforce: any failure is non-fatal (fail-safe). If shopdb + is unreachable the client enforces from the last-known-good cached manifest and + a PC is never left unmanaged because the web app is down. + + Config (params override registry): HKLM:\SOFTWARE\GE\ShopDB + BaseUrl e.g. https://shopdb.example.geaerospace.net + ApiToken a geenforce.fetch (+ geenforce.report) managed service token, + provisioned the same way as SFLD creds (Azure DSC). +#> + +Set-StrictMode -Version Latest + +function Get-ShopdbConfig { + param([string]$BaseUrl, [string]$ApiToken) + $regPath = 'HKLM:\SOFTWARE\GE\ShopDB' + if ((-not $BaseUrl -or -not $ApiToken) -and (Test-Path $regPath)) { + $props = Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue + if (-not $BaseUrl -and $props.BaseUrl) { $BaseUrl = $props.BaseUrl } + if (-not $ApiToken -and $props.ApiToken) { $ApiToken = $props.ApiToken } + } + if (-not $BaseUrl -or -not $ApiToken) { return $null } + return @{ BaseUrl = $BaseUrl.TrimEnd('/'); ApiToken = $ApiToken } +} + +function Sync-ShopdbManifest { + <# + Fetch the current published manifest for a scope into a local cache, using + an ETag so an unchanged manifest is a cheap 304. On any network error, fall + back to the last-known-good cached copy. Returns: + @{ Path; Version; Source } where Source is + 'shopdb' | 'cache-304' | 'cache-lastgood' | $null (nothing available) + #> + param( + [Parameter(Mandatory)] [string]$Scope, + [Parameter(Mandatory)] [hashtable]$Config, + [string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce' + ) + if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null } + $manifestPath = Join-Path $CacheDir "$Scope.json" + $etagPath = Join-Path $CacheDir "$Scope.etag" + $headers = @{ 'X-API-Key' = $Config.ApiToken } + if (Test-Path $etagPath) { $headers['If-None-Match'] = (Get-Content -LiteralPath $etagPath -Raw).Trim() } + + $uri = "$($Config.BaseUrl)/api/geenforce/manifest?pctype=$([uri]::EscapeDataString($Scope))" + try { + $response = Invoke-WebRequest -Uri $uri -Headers $headers -UseBasicParsing ` + -TimeoutSec 30 -ErrorAction Stop + if ($response.StatusCode -eq 200) { + [System.IO.File]::WriteAllText($manifestPath, $response.Content) + # PowerShell 7 returns header values as string arrays; 5.1 as scalars. + # @(...)[0] yields a clean scalar in both. + $etag = @($response.Headers['ETag'])[0] + if ($etag) { + Set-Content -LiteralPath $etagPath -Value $etag -NoNewline + } + $version = @($response.Headers['X-Manifest-Version'])[0] + if ($version) { + Set-Content -LiteralPath (Join-Path $CacheDir "$Scope.version") -Value $version -NoNewline + } + return @{ Path = $manifestPath; Version = $version; Source = 'shopdb' } + } + } catch { + $status = $null + if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } + if ($status -eq 304 -and (Test-Path $manifestPath)) { + return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' } + } + # Network/other failure: fall back to last-known-good. + if (Test-Path $manifestPath) { + return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' } + } + return @{ Path = $null; Version = $null; Source = $null } + } + # 304 without exception (some PS versions) -> use cache. + if (Test-Path $manifestPath) { + return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' } + } + return @{ Path = $null; Version = $null; Source = $null } +} + +function Read-CachedVersion { + param([string]$CacheDir, [string]$Scope) + $verPath = Join-Path $CacheDir "$Scope.version" + if (Test-Path $verPath) { return (Get-Content -LiteralPath $verPath -Raw).Trim() } + return $null +} + +function Compare-ShopdbShadow { + <# + Shadow-mode comparison: do the shopdb manifest and the on-share manifest + select the same ordered entry names? Returns @{ Same; ShopdbOnly; ShareOnly; + OrderDiff }. Behavioral, not byte, comparison. + #> + param([Parameter(Mandatory)][string]$ShopdbManifestPath, + [Parameter(Mandatory)][string]$ShareManifestPath) + $shopdb = (Get-Content -LiteralPath $ShopdbManifestPath -Raw | ConvertFrom-Json) + $share = (Get-Content -LiteralPath $ShareManifestPath -Raw | ConvertFrom-Json) + $shopdbNames = @($shopdb.Applications | ForEach-Object { $_.Name }) + $shareNames = @($share.Applications | ForEach-Object { $_.Name }) + return @{ + Same = (($shopdbNames -join '|') -eq ($shareNames -join '|')) + ShopdbOnly = @($shopdbNames | Where-Object { $_ -notin $shareNames }) + ShareOnly = @($shareNames | Where-Object { $_ -notin $shopdbNames }) + OrderDiff = (($shopdbNames -join '|') -ne ($shareNames -join '|')) + } +} + +function Send-ShopdbReport { + <# + POST an enforcement report to shopdb. Best-effort: never throws, returns + $true on success. Report is a hashtable matching POST /api/geenforce/report. + #> + param([Parameter(Mandatory)][hashtable]$Config, + [Parameter(Mandatory)][hashtable]$Report) + try { + $body = ($Report | ConvertTo-Json -Depth 6) + Invoke-RestMethod -Uri "$($Config.BaseUrl)/api/geenforce/report" ` + -Method Post -Headers @{ 'X-API-Key' = $Config.ApiToken } ` + -ContentType 'application/json' -Body $body -TimeoutSec 30 -ErrorAction Stop | Out-Null + return $true + } catch { + return $false + } +} + +function New-ShopdbReport { + <# + Build a report payload from an engine summary. `Summary` is expected to + carry Installed/Skipped/Failed/Filtered counts and a Results list of + @{ Name; Action; SelfHealed; ExitCode; Message }. Shape the engine's own + per-entry outcomes into this at the call site. + #> + param([string]$Hostname = $env:COMPUTERNAME, + [Parameter(Mandatory)][string]$Scope, + [int]$AppliedVersion, + [Parameter(Mandatory)][hashtable]$Summary) + return @{ + hostname = $Hostname + scopename = $Scope + appliedversion = $AppliedVersion + enforcerversion = $Summary.EnforcerVersion + counts = @{ + installed = [int]$Summary.Installed + skipped = [int]$Summary.Skipped + failed = [int]$Summary.Failed + filtered = [int]$Summary.Filtered + } + results = @($Summary.Results) + } +} + +Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, ` + Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion diff --git a/plugins/geenforce/filters.py b/plugins/geenforce/filters.py new file mode 100644 index 00000000..7e1e3ca2 --- /dev/null +++ b/plugins/geenforce/filters.py @@ -0,0 +1,144 @@ +"""Python mirror of the GE-Enforce engine's targeting filters. + +These re-implement, field for field, the four filter functions in +`Install-FromManifest.ps1` (Test-PCTypeMatches, Test-HostnameMatches, +Test-MachineNumberMatches, Test-CmmVersionMatches) plus the PCTypes alias graph +(lines 463-475). They operate on plain manifest-entry dicts (the exact shape of +an Applications[] entry) and a machine-profile dict, so the SAME code answers +two questions: + + 1. Parity harness: do the on-share manifest and the shopdb-rebuilt manifest + select the same entries in the same order for a set of machine profiles? + 2. Simulator: "what would PC Y get" - which entries apply and why. + +The engine lib stays the single source of truth; this is a mirror. If the lib's +alias map changes, ALIAS_GROUPS here must change with it (the parity harness's +legacy-name profiles fail loudly on divergence). +""" + +import fnmatch + +# Mirror of $script:_pcTypeAliasGroups in Install-FromManifest.ps1:463-475. +# Rows are alias sets: names in the same set all match the same identity. +ALIAS_GROUPS = [ + ('Standard', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections', + 'gea-shopfloor-common'), + ('Standard-Machine', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections'), + ('Standard-Timeclock', 'gea-shopfloor-common'), + ('CMM', 'gea-shopfloor-cmm'), + ('Keyence', 'gea-shopfloor-keyence'), + ('Lab', 'gea-shopfloor-common'), + ('WaxAndTrace', 'gea-shopfloor-waxtrace'), + ('Genspect', 'gea-shopfloor-genspect'), + ('Display', 'gea-shopfloor-display'), + ('Heattreat', 'gea-shopfloor-heattreat'), + ('PartMarker', 'gea-shopfloor-partmarker'), +] + + +def _alias_sets(name): + """Every alias group containing `name` (case-insensitive).""" + lname = (name or '').lower() + return [g for g in ALIAS_GROUPS if any(n.lower() == lname for n in g)] + + +def matches_pctype(entry, pctype, subtype=None, strict_allowed=False): + """Test-PCTypeMatches: no PCTypes = all; '*' = all; alias-set intersection. + + PCTypesStrict=true disables alias expansion (both the PC's identity and the + manifest values are compared literally), so a 'collections'-only strict entry + does NOT match a 'nocollections' PC via their shared 'Standard' alias group. + IMPORTANT: only the PREINSTALL runner honors this flag - the runtime engine + (Install-FromManifest.ps1's Test-PCTypeMatches) has no strict handling. So + strict is applied only when strict_allowed (i.e. phase == 'preinstall'), + else a runtime entry with PCTypesStrict still alias-expands, matching the + real engine. + """ + values = entry.get('PCTypes') or [] + if not values: + return True + if not pctype: + return True + strict = strict_allowed and bool(entry.get('PCTypesStrict')) + # Names the current PC matches: bare type, "type-subtype", and (unless strict) + # all aliases of either. + mynames = set() + mynames.add(pctype.lower()) + seeds = [pctype] + if subtype: + combined = f'{pctype}-{subtype}' + mynames.add(combined.lower()) + seeds.append(combined) + if not strict: + for seed in seeds: + for group in _alias_sets(seed): + for alias in group: + mynames.add(alias.lower()) + for value in values: + if value == '*': + return True + if value.lower() in mynames: + return True + if strict: + continue + # The manifest value may itself be an alias - expand and check overlap. + for group in _alias_sets(value): + for alias in group: + if alias.lower() in mynames: + return True + return False + + +def matches_hostname(entry, hostname): + """Test-HostnameMatches: exact or PowerShell -like glob (*, ?, [set]).""" + patterns = entry.get('TargetHostnames') or [] + if not patterns: + return True + myname = (hostname or '').lower() + for pattern in patterns: + lpattern = (pattern or '').lower() + if lpattern == myname: + return True + if fnmatch.fnmatch(myname, lpattern): + return True + return False + + +def matches_machinenumber(entry, machinenumber): + """Test-MachineNumberMatches: filter present but PC has no number = exclude.""" + numbers = entry.get('TargetMachineNumbers') or [] + if not numbers: + return True + if not machinenumber: + return False + mynumber = str(machinenumber).lower() + return any(str(n).lower() == mynumber for n in numbers) + + +def matches_cmmversion(entry, cmmversion): + """Test-CmmVersionMatches: untagged always applies; no PC version = install-all.""" + tag = entry.get('_CmmVersion') + if not tag: + return True + if not cmmversion: + return True + return str(tag).lower() == str(cmmversion).lower() + + +def entry_applies(entry, profile): + """All four filters ANDed, exactly as the engine's main loop applies them. + + `profile` keys: pctype, subtype, hostname, machinenumber, cmmversion, and + optional phase ('preinstall' enables PCTypesStrict, matching the runners). + """ + strict_allowed = profile.get('phase') == 'preinstall' + return (matches_pctype(entry, profile.get('pctype'), profile.get('subtype'), + strict_allowed) + and matches_hostname(entry, profile.get('hostname')) + and matches_machinenumber(entry, profile.get('machinenumber')) + and matches_cmmversion(entry, profile.get('cmmversion'))) + + +def applicable_entry_names(applications, profile): + """Ordered list of entry Names that pass all filters for a profile.""" + return [e.get('Name') for e in applications if entry_applies(e, profile)] diff --git a/plugins/geenforce/importer.py b/plugins/geenforce/importer.py new file mode 100644 index 00000000..684f588b --- /dev/null +++ b/plugins/geenforce/importer.py @@ -0,0 +1,117 @@ +"""Parse on-share manifest.json / preinstall.json into draft DB rows. + +The reverse of serializer.py. `build_scope` turns a parsed manifest dict into a +ManifestScope with its ordered entries and child rows (not yet committed). +`discover_share` walks a GE-Enforce share root and yields (scopename, phase, +manifest_dict) for common + every gea-shopfloor-* runtime manifest, skipping +`.bak` variants. `load_preinstall` reads a preinstall.json. + +Import is a rebuild: the caller replaces a scope's draft rows so re-running is +idempotent. +""" + +import json +import os + +from .models import ( + ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname, + ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess, +) +from .serializer import _SCALAR_FIELDS, _BOOL_FLAGS + +# manifest key -> model attr (reverse of the serializer's scalar map). +_KEY_TO_ATTR = {mk: attr for attr, mk in _SCALAR_FIELDS} +_FLAG_TO_ATTR = {mk: attr for attr, mk in _BOOL_FLAGS} + + +# Scalar model attributes that a populate resets (so an update clears fields the +# new payload omits). name/entrytype are required and set explicitly. +_RESETTABLE_ATTRS = [attr for _, attr in _SCALAR_FIELDS + if attr not in ('name', 'entrytype')] + ['comment', 'regvalue'] + + +def populate_entry(entry, entry_dict): + """Set every field + child on an existing (or new) ManifestEntry from one + Applications[] entry dict. Existing children are replaced. Returns entry.""" + entry.name = entry_dict.get('Name') + entry.entrytype = entry_dict.get('Type') + entry.comment = entry_dict.get('_comment') + for attr in _RESETTABLE_ATTRS: + if attr in ('comment', 'regvalue'): + continue + setattr(entry, attr, None) + for key, attr in _KEY_TO_ATTR.items(): + if attr in ('name', 'entrytype'): + continue + if key in entry_dict and entry_dict[key] is not None: + setattr(entry, attr, entry_dict[key]) + # RegValue stored as its raw JSON literal so DWord vs string typing survives. + entry.regvalue = (json.dumps(entry_dict['RegValue']) + if 'RegValue' in entry_dict else None) + for key, attr in _FLAG_TO_ATTR.items(): + setattr(entry, attr, bool(entry_dict.get(key))) + # Multi-value filters -> child rows (replace, preserve order). + entry.pctypes = [ManifestEntryPcType(sortorder=i, pctypevalue=value) + for i, value in enumerate(entry_dict.get('PCTypes') or [])] + entry.hostnames = [ManifestEntryHostname(sortorder=i, hostnamepattern=value) + for i, value in enumerate(entry_dict.get('TargetHostnames') or [])] + entry.machinenumbers = [ + ManifestEntryMachineNumber(sortorder=i, machinenumber=str(value)) + for i, value in enumerate(entry_dict.get('TargetMachineNumbers') or [])] + # InUseCheck object + Processes[]. + inuse = entry_dict.get('InUseCheck') + if inuse: + check = ManifestInUseCheck(behavior=inuse.get('Behavior')) + for i, proc in enumerate(inuse.get('Processes') or []): + check.processes.append(ManifestInUseCheckProcess( + sortorder=i, + processname=proc.get('Name'), + exepath=proc.get('ExePath'), + gracefulclosetimeoutsec=proc.get('GracefulCloseTimeoutSec'))) + entry.inusecheck = check + else: + entry.inusecheck = None + return entry + + +def build_entry(entry_dict, sortorder): + """Build a new ManifestEntry (+ children) from one Applications[] entry.""" + entry = ManifestEntry(sortorder=sortorder) + return populate_entry(entry, entry_dict) + + +def build_scope(scopename, phase, manifest_dict, iscommon=False): + """Build a ManifestScope (+ entries) from a parsed manifest dict.""" + scope = ManifestScope( + scopename=scopename, + phase=phase, + manifestversion=str(manifest_dict.get('Version', '1.0')), + topcomment=manifest_dict.get('_comment'), + site=manifest_dict.get('Site'), + iscommon=iscommon) + for i, entry_dict in enumerate(manifest_dict.get('Applications') or []): + scope.entries.append(build_entry(entry_dict, i)) + return scope + + +def discover_share(shareroot): + """Yield (scopename, phase, manifest_dict) for runtime manifests on a share. + + Reads `common/manifest.json` and every `gea-shopfloor-*/manifest.json`. + Skips `.bak` / `.pre-*.bak` variants (only the exact `manifest.json`). + """ + for name in sorted(os.listdir(shareroot)): + if name != 'common' and not name.startswith('gea-shopfloor-'): + continue + path = os.path.join(shareroot, name, 'manifest.json') + if not os.path.isfile(path): + continue + with open(path) as handle: + manifest = json.load(handle) + yield name, 'runtime', manifest + + +def load_manifest_file(path): + """Parse a single manifest.json / preinstall.json file.""" + with open(path) as handle: + return json.load(handle) diff --git a/plugins/geenforce/manifest.json b/plugins/geenforce/manifest.json new file mode 100644 index 00000000..35e50bf9 --- /dev/null +++ b/plugins/geenforce/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "geenforce", + "display_name": "GE-Enforce", + "version": "0.1.0", + "description": "GE-Enforce manifest store. Owns imaging PC-type scopes and their install manifests (apps, scripts, files, registry, version gates) as shopdb data, served to the GE-Enforce client as JSON. Requires GE-Enforce lib >= 2.6 on target PCs (the _CmmVersion gate).", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.7.0,<1.0.0", + "api_prefix": "/api/geenforce", + "default_enabled": false, + "provides": { + "features": ["manifest-store", "imaging-pc-types"] + } +} diff --git a/plugins/geenforce/migrations/env.py b/plugins/geenforce/migrations/env.py new file mode 100644 index 00000000..bb8998d8 --- /dev/null +++ b/plugins/geenforce/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the GE-Enforce 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_geenforce (ADR-008). This plugin is NEW: its 0001 +baseline really CREATES its tables, because the core chain never built them. +""" +import os + +os.environ['PLUGIN_NAME'] = 'geenforce' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/geenforce/migrations/script.py.mako b/plugins/geenforce/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/geenforce/migrations/script.py.mako @@ -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"} diff --git a/plugins/geenforce/migrations/versions/0001_geenforce_baseline.py b/plugins/geenforce/migrations/versions/0001_geenforce_baseline.py new file mode 100644 index 00000000..2c9831f5 --- /dev/null +++ b/plugins/geenforce/migrations/versions/0001_geenforce_baseline.py @@ -0,0 +1,241 @@ +"""geenforce plugin baseline (real create). + +This plugin was built after the ADR-008 ownership cutover, so this baseline +actually CREATES the plugin's tables (the core chain never knew about them). It +runs from `flask plugin install geenforce` / `flask plugin upgrade-all` after +`flask db upgrade` builds the core schema. All foreign keys are intra-plugin, so +tables are created parent-first. computertypeid / measuringtooltypeid / +publishedby are SOFT references (plain columns, no FK) to optional plugins/core. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'geenforce0001baseline' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'manifestscopes', + sa.Column('scopeid', sa.Integer(), nullable=False), + sa.Column('scopename', sa.String(length=64), nullable=False), + sa.Column('phase', sa.String(length=16), nullable=False), + sa.Column('computertypeid', sa.Integer(), nullable=True), + sa.Column('measuringtooltypeid', sa.Integer(), nullable=True), + sa.Column('manifestversion', sa.String(length=16), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('topcomment', sa.Text(), nullable=True), + sa.Column('site', sa.String(length=100), nullable=True), + sa.Column('iscommon', sa.Boolean(), nullable=False), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('scopeid'), + sa.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'), + ) + op.create_table( + 'manifestentries', + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('scopeid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('entrytype', sa.String(length=16), nullable=False), + sa.Column('comment', sa.Text(), nullable=True), + sa.Column('installer', sa.String(length=255), nullable=True), + sa.Column('installargs', sa.Text(), nullable=True), + sa.Column('scriptpath', sa.String(length=255), nullable=True), + sa.Column('scriptargs', sa.String(length=255), nullable=True), + sa.Column('sourcepath', sa.String(length=255), nullable=True), + sa.Column('destination', sa.String(length=255), nullable=True), + sa.Column('regpath', sa.String(length=255), nullable=True), + sa.Column('regname', sa.String(length=128), nullable=True), + sa.Column('regvalue', sa.Text(), nullable=True), + sa.Column('regtype', sa.String(length=16), nullable=True), + sa.Column('payloadsource', sa.String(length=8), nullable=False), + sa.Column('payloadref', sa.String(length=512), nullable=True), + sa.Column('payloadsha256', sa.String(length=64), nullable=True), + sa.Column('detectionmethod', sa.String(length=16), nullable=True), + sa.Column('detectionpath', sa.String(length=255), nullable=True), + sa.Column('detectionname', sa.String(length=128), nullable=True), + sa.Column('detectionvalue', sa.String(length=255), nullable=True), + sa.Column('detectionpattern', sa.String(length=255), nullable=True), + sa.Column('cmmversion', sa.String(length=16), nullable=True), + sa.Column('logfile', sa.String(length=255), nullable=True), + sa.Column('waittimeoutsec', sa.Integer(), nullable=True), + sa.Column('applymode', sa.String(length=16), nullable=True), + sa.Column('updatewindow', sa.String(length=11), nullable=True), + sa.Column('appid', sa.Integer(), nullable=True), + sa.Column('preenrollment', sa.Boolean(), nullable=False), + sa.Column('killafterdetection', sa.Boolean(), nullable=False), + sa.Column('pctypesstrict', sa.Boolean(), nullable=False), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['scopeid'], ['manifestscopes.scopeid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('entryid'), + sa.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'), + ) + op.create_index('idx_entry_scope', 'manifestentries', ['scopeid']) + op.create_index('idx_entry_scope_order', 'manifestentries', + ['scopeid', 'sortorder']) + op.create_table( + 'manifestentrypctypes', + sa.Column('entrypctypeid', sa.Integer(), nullable=False), + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False), + sa.Column('pctypevalue', sa.String(length=64), nullable=False), + sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('entrypctypeid'), + ) + op.create_index('idx_pctype_entry', 'manifestentrypctypes', ['entryid']) + op.create_table( + 'manifestentryhostnames', + sa.Column('entryhostnameid', sa.Integer(), nullable=False), + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False), + sa.Column('hostnamepattern', sa.String(length=64), nullable=False), + sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('entryhostnameid'), + ) + op.create_index('idx_hostname_entry', 'manifestentryhostnames', ['entryid']) + op.create_table( + 'manifestentrymachinenumbers', + sa.Column('entrymachinenumberid', sa.Integer(), nullable=False), + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False), + sa.Column('machinenumber', sa.String(length=16), nullable=False), + sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('entrymachinenumberid'), + ) + op.create_index('idx_machinenumber_entry', 'manifestentrymachinenumbers', + ['entryid']) + op.create_table( + 'manifestinusechecks', + sa.Column('inusecheckid', sa.Integer(), nullable=False), + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('behavior', sa.String(length=20), nullable=False), + sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('inusecheckid'), + sa.UniqueConstraint('entryid'), + ) + op.create_table( + 'manifestinusecheckprocesses', + sa.Column('inusecheckprocessid', sa.Integer(), nullable=False), + sa.Column('inusecheckid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False), + sa.Column('processname', sa.String(length=64), nullable=False), + sa.Column('exepath', sa.String(length=255), nullable=True), + sa.Column('gracefulclosetimeoutsec', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['inusecheckid'], + ['manifestinusechecks.inusecheckid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('inusecheckprocessid'), + ) + op.create_index('idx_process_check', 'manifestinusecheckprocesses', + ['inusecheckid']) + op.create_table( + 'manifestpublishedversions', + sa.Column('publishedversionid', sa.Integer(), nullable=False), + sa.Column('scopeid', sa.Integer(), nullable=False), + sa.Column('versionnumber', sa.Integer(), nullable=False), + sa.Column('manifestjson', sa.Text(length=16777215), nullable=False), + sa.Column('publishedat', sa.DateTime(), nullable=False), + sa.Column('publishedby', sa.Integer(), nullable=True), + sa.Column('iscurrent', sa.Boolean(), nullable=False), + sa.Column('notes', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['scopeid'], ['manifestscopes.scopeid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('publishedversionid'), + sa.UniqueConstraint('scopeid', 'versionnumber', + name='uq_published_scope_version'), + ) + op.create_index('idx_published_scope', 'manifestpublishedversions', + ['scopeid']) + op.create_table( + 'manifestpayloads', + sa.Column('payloadid', sa.Integer(), nullable=False), + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('filename', sa.String(length=255), nullable=False), + sa.Column('contenttype', sa.String(length=128), nullable=True), + sa.Column('payloadbytes', sa.LargeBinary(length=16777215), nullable=False), + sa.Column('payloadsha256', sa.String(length=64), nullable=False), + sa.Column('uploadedat', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('payloadid'), + ) + op.create_index('idx_payload_entry', 'manifestpayloads', ['entryid']) + op.create_table( + 'manifestenforcementreports', + sa.Column('reportid', sa.Integer(), nullable=False), + sa.Column('hostname', sa.String(length=100), nullable=False), + sa.Column('scopename', sa.String(length=64), nullable=False), + sa.Column('phase', sa.String(length=16), nullable=False), + sa.Column('appliedversion', sa.Integer(), nullable=True), + sa.Column('enforcerversion', sa.String(length=20), nullable=True), + sa.Column('installedcount', sa.Integer(), nullable=False), + sa.Column('skippedcount', sa.Integer(), nullable=False), + sa.Column('failedcount', sa.Integer(), nullable=False), + sa.Column('filteredcount', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('lastcheckin', sa.DateTime(), nullable=True), + sa.Column('receivedat', sa.DateTime(), nullable=False), + sa.Column('iscurrent', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('reportid'), + ) + op.create_index('idx_report_host', 'manifestenforcementreports', + ['hostname']) + op.create_index('idx_report_current', 'manifestenforcementreports', + ['iscurrent']) + op.create_index('idx_report_host_scope', 'manifestenforcementreports', + ['hostname', 'scopename', 'phase']) + op.create_table( + 'manifestenforcementresults', + sa.Column('resultid', sa.Integer(), nullable=False), + sa.Column('reportid', sa.Integer(), nullable=False), + sa.Column('entryname', sa.String(length=128), nullable=False), + sa.Column('action', sa.String(length=16), nullable=False), + sa.Column('selfhealed', sa.Boolean(), nullable=False), + sa.Column('exitcode', sa.Integer(), nullable=True), + sa.Column('message', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['reportid'], + ['manifestenforcementreports.reportid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('resultid'), + ) + op.create_index('idx_result_report', 'manifestenforcementresults', + ['reportid']) + op.create_table( + 'pctypealiases', + sa.Column('aliasid', sa.Integer(), nullable=False), + sa.Column('aliasgroup', sa.Integer(), nullable=False), + sa.Column('aliasname', sa.String(length=64), nullable=False), + sa.PrimaryKeyConstraint('aliasid'), + sa.UniqueConstraint('aliasgroup', 'aliasname', + name='uq_alias_group_name'), + ) + op.create_index('idx_alias_group', 'pctypealiases', ['aliasgroup']) + + +def downgrade(): + op.drop_table('pctypealiases') + op.drop_table('manifestenforcementresults') + op.drop_table('manifestenforcementreports') + op.drop_table('manifestpayloads') + op.drop_table('manifestpublishedversions') + op.drop_table('manifestinusecheckprocesses') + op.drop_table('manifestinusechecks') + op.drop_table('manifestentrymachinenumbers') + op.drop_table('manifestentryhostnames') + op.drop_table('manifestentrypctypes') + op.drop_table('manifestentries') + op.drop_table('manifestscopes') diff --git a/plugins/geenforce/models/__init__.py b/plugins/geenforce/models/__init__.py new file mode 100644 index 00000000..bf3e31af --- /dev/null +++ b/plugins/geenforce/models/__init__.py @@ -0,0 +1,45 @@ +"""GE-Enforce plugin models.""" + +from .manifest import ( + ManifestScope, + ManifestEntry, + ManifestEntryPcType, + ManifestEntryHostname, + ManifestEntryMachineNumber, + ManifestInUseCheck, + ManifestInUseCheckProcess, + ManifestPublishedVersion, + ManifestPayload, + ManifestEnforcementReport, + ManifestEnforcementResult, + PcTypeAlias, + PHASES, + ENTRY_TYPES, + REG_TYPES, + PAYLOAD_SOURCES, + DETECTION_METHODS, + APPLY_MODES, + INUSE_BEHAVIORS, +) + +__all__ = [ + 'ManifestScope', + 'ManifestEntry', + 'ManifestEntryPcType', + 'ManifestEntryHostname', + 'ManifestEntryMachineNumber', + 'ManifestInUseCheck', + 'ManifestInUseCheckProcess', + 'ManifestPublishedVersion', + 'ManifestPayload', + 'ManifestEnforcementReport', + 'ManifestEnforcementResult', + 'PcTypeAlias', + 'PHASES', + 'ENTRY_TYPES', + 'REG_TYPES', + 'PAYLOAD_SOURCES', + 'DETECTION_METHODS', + 'APPLY_MODES', + 'INUSE_BEHAVIORS', +] diff --git a/plugins/geenforce/models/manifest.py b/plugins/geenforce/models/manifest.py new file mode 100644 index 00000000..75e41dfb --- /dev/null +++ b/plugins/geenforce/models/manifest.py @@ -0,0 +1,353 @@ +"""GE-Enforce manifest-store models. + +The manifest that GE-Enforce runs on each PC becomes shopdb data. A +`ManifestScope` is one imaging PC type (a `gea-shopfloor-*` scope, the `common` +fleet-wide scope, or the single flat `preinstall` scope). Each scope owns an +ordered list of `ManifestEntry` rows - one per Applications[] entry in the +on-share manifest.json - plus child rows for the multi-value targeting filters +and the nested InUseCheck. + +Design choices (see the ge-enforce-plugin proposal in the project wiki): +- ONE wide entries table with an `entrytype` discriminator and nullable + per-type columns. Not STI, not a JSON blob: the whole fleet is ~64 entries, + so sparse columns are free and every field stays queryable + plain-SQL + readable by IT. +- Array order IS execution order, so `sortorder` is a real column and the + contract; config-restore entries sit after their installer on purpose. +- `computertypeid` / `measuringtooltypeid` are SOFT references (plain Integer, + no DB foreign key) to other plugins' tables, which may not be installed. +- Published manifests are immutable snapshots that freeze the rendered JSON + document (`ManifestPublishedVersion`); the draft (`ManifestEntry`) is never + served to a client. +""" + +from shopdb.api import db, BaseModel + + +# Allowed enumerations, validated in the API/service layer (stored as strings +# for portability, matching the rest of the codebase). +PHASES = ('runtime', 'preinstall') +ENTRY_TYPES = ('MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry') +REG_TYPES = ('String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary') +PAYLOAD_SOURCES = ('smb', 'http', 'inline') +DETECTION_METHODS = ('Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile', + 'ValueMatches', 'pnputil', 'Always') +APPLY_MODES = ('Nightly', 'Immediate', 'ImmediateReboot') +INUSE_BEHAVIORS = ('Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot') + + +class ManifestScope(BaseModel): + """One imaging PC type / manifest scope (the working/draft head).""" + __tablename__ = 'manifestscopes' + + scopeid = db.Column(db.Integer, primary_key=True) + # 'common', 'gea-shopfloor-cmm', 'preinstall', ... + scopename = db.Column(db.String(64), nullable=False) + # 'runtime' (per-pctype manifest.json) or 'preinstall' (one flat manifest) + phase = db.Column(db.String(16), nullable=False, default='runtime') + # Soft refs (no FK): the plugins that own these tables are optional. + computertypeid = db.Column(db.Integer, nullable=True) + measuringtooltypeid = db.Column(db.Integer, nullable=True) + manifestversion = db.Column(db.String(16), nullable=False, default='1.0') + description = db.Column(db.String(255), nullable=True) + # The manifest-level '_comment' (documentation), preserved verbatim so + # export-to-share round-trips it. Excluded from behavioral parity. + topcomment = db.Column(db.Text, nullable=True) + iscommon = db.Column(db.Boolean, nullable=False, default=False) + # Preinstall manifests carry a top-level 'Site' field. + site = db.Column(db.String(100), nullable=True) + + entries = db.relationship( + 'ManifestEntry', back_populates='scope', + cascade='all, delete-orphan', order_by='ManifestEntry.sortorder', + lazy='selectin') + publishedversions = db.relationship( + 'ManifestPublishedVersion', back_populates='scope', + cascade='all, delete-orphan', + order_by='ManifestPublishedVersion.versionnumber', lazy='dynamic') + + __table_args__ = ( + db.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'), + ) + + def __repr__(self): + return f"" + + +class ManifestEntry(BaseModel): + """One Applications[] entry (draft). Wide table, entrytype discriminator.""" + __tablename__ = 'manifestentries' + + entryid = db.Column(db.Integer, primary_key=True) + scopeid = db.Column( + db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'), + nullable=False, index=True) + sortorder = db.Column(db.Integer, nullable=False, default=0) + name = db.Column(db.String(128), nullable=False) + entrytype = db.Column(db.String(16), nullable=False) + comment = db.Column(db.Text, nullable=True) # manifest '_comment' + + # Type-specific payload references (nullable; validated per entrytype). + installer = db.Column(db.String(255), nullable=True) + installargs = db.Column(db.Text, nullable=True) # can run long + scriptpath = db.Column(db.String(255), nullable=True) # 'Script' + scriptargs = db.Column(db.String(255), nullable=True) # 'Args' + sourcepath = db.Column(db.String(255), nullable=True) # 'Source' + destination = db.Column(db.String(255), nullable=True) + regpath = db.Column(db.String(255), nullable=True) + regname = db.Column(db.String(128), nullable=True) + # Raw JSON literal preserved verbatim (1 vs "1"); DWord typing depends on it. + regvalue = db.Column(db.Text, nullable=True) + regtype = db.Column(db.String(16), nullable=True) + + # Payload transport + integrity (integrity hash is SEPARATE from detection). + payloadsource = db.Column(db.String(8), nullable=False, default='smb') + payloadref = db.Column(db.String(512), nullable=True) + payloadsha256 = db.Column(db.String(64), nullable=True) + + # Detection (decides whether the action fires / self-heals). + detectionmethod = db.Column(db.String(16), nullable=True) + detectionpath = db.Column(db.String(255), nullable=True) + detectionname = db.Column(db.String(128), nullable=True) + detectionvalue = db.Column(db.String(255), nullable=True) + detectionpattern = db.Column(db.String(255), nullable=True) + + # Gates + control. + cmmversion = db.Column(db.String(16), nullable=True) # '_CmmVersion' + logfile = db.Column(db.String(255), nullable=True) + waittimeoutsec = db.Column(db.Integer, nullable=True) + applymode = db.Column(db.String(16), nullable=True) # inert in engine today + updatewindow = db.Column(db.String(11), nullable=True) # 'HH:MM-HH:MM', inert + + # Optional CURATED link to a core Applications catalog row (soft ref, no FK + # to keep the plugin decoupled). shopdb metadata only - NOT part of the + # manifest JSON the engine reads, so it never affects enforcement or parity. + appid = db.Column(db.Integer, nullable=True) + + # Preinstall-only flags. + preenrollment = db.Column(db.Boolean, nullable=False, default=False) + killafterdetection = db.Column(db.Boolean, nullable=False, default=False) + pctypesstrict = db.Column(db.Boolean, nullable=False, default=False) + + scope = db.relationship('ManifestScope', back_populates='entries') + pctypes = db.relationship( + 'ManifestEntryPcType', back_populates='entry', + cascade='all, delete-orphan', order_by='ManifestEntryPcType.sortorder', + lazy='selectin') + hostnames = db.relationship( + 'ManifestEntryHostname', back_populates='entry', + cascade='all, delete-orphan', order_by='ManifestEntryHostname.sortorder', + lazy='selectin') + machinenumbers = db.relationship( + 'ManifestEntryMachineNumber', back_populates='entry', + cascade='all, delete-orphan', + order_by='ManifestEntryMachineNumber.sortorder', lazy='selectin') + inusecheck = db.relationship( + 'ManifestInUseCheck', back_populates='entry', uselist=False, + cascade='all, delete-orphan', lazy='selectin') + + __table_args__ = ( + db.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'), + db.Index('idx_entry_scope_order', 'scopeid', 'sortorder'), + ) + + def __repr__(self): + return f"" + + +class ManifestEntryPcType(db.Model): + """One value of an entry's PCTypes filter (verbatim, incl. '*' and aliases).""" + __tablename__ = 'manifestentrypctypes' + + entrypctypeid = db.Column(db.Integer, primary_key=True) + entryid = db.Column( + db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'), + nullable=False, index=True) + sortorder = db.Column(db.Integer, nullable=False, default=0) + pctypevalue = db.Column(db.String(64), nullable=False) + + entry = db.relationship('ManifestEntry', back_populates='pctypes') + + +class ManifestEntryHostname(db.Model): + """One value of an entry's TargetHostnames filter (wildcards kept verbatim).""" + __tablename__ = 'manifestentryhostnames' + + entryhostnameid = db.Column(db.Integer, primary_key=True) + entryid = db.Column( + db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'), + nullable=False, index=True) + sortorder = db.Column(db.Integer, nullable=False, default=0) + hostnamepattern = db.Column(db.String(64), nullable=False) + + entry = db.relationship('ManifestEntry', back_populates='hostnames') + + +class ManifestEntryMachineNumber(db.Model): + """One value of an entry's TargetMachineNumbers filter.""" + __tablename__ = 'manifestentrymachinenumbers' + + entrymachinenumberid = db.Column(db.Integer, primary_key=True) + entryid = db.Column( + db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'), + nullable=False, index=True) + sortorder = db.Column(db.Integer, nullable=False, default=0) + machinenumber = db.Column(db.String(16), nullable=False) + + entry = db.relationship('ManifestEntry', back_populates='machinenumbers') + + +class ManifestInUseCheck(db.Model): + """The InUseCheck object on an entry (0..1), with its Processes[] children.""" + __tablename__ = 'manifestinusechecks' + + inusecheckid = db.Column(db.Integer, primary_key=True) + entryid = db.Column( + db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'), + nullable=False, unique=True) + behavior = db.Column(db.String(20), nullable=False) + + entry = db.relationship('ManifestEntry', back_populates='inusecheck') + processes = db.relationship( + 'ManifestInUseCheckProcess', back_populates='inusecheck', + cascade='all, delete-orphan', + order_by='ManifestInUseCheckProcess.sortorder', lazy='selectin') + + +class ManifestInUseCheckProcess(db.Model): + """One process in an InUseCheck's Processes[] list.""" + __tablename__ = 'manifestinusecheckprocesses' + + inusecheckprocessid = db.Column(db.Integer, primary_key=True) + inusecheckid = db.Column( + db.Integer, + db.ForeignKey('manifestinusechecks.inusecheckid', ondelete='CASCADE'), + nullable=False, index=True) + sortorder = db.Column(db.Integer, nullable=False, default=0) + processname = db.Column(db.String(64), nullable=False) + exepath = db.Column(db.String(255), nullable=True) + # Null = engine default (10); do not bake the default into the row. + gracefulclosetimeoutsec = db.Column(db.Integer, nullable=True) + + inusecheck = db.relationship('ManifestInUseCheck', back_populates='processes') + + +class ManifestPublishedVersion(db.Model): + """Immutable published snapshot: the frozen rendered JSON document. + + The client is always served the `iscurrent` snapshot for a scope, never the + live draft. Rollback flips `iscurrent`. Freezing the text (not row-mirroring) + makes immutability structural and rollback a one-flag change. + """ + __tablename__ = 'manifestpublishedversions' + + publishedversionid = db.Column(db.Integer, primary_key=True) + scopeid = db.Column( + db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'), + nullable=False, index=True) + versionnumber = db.Column(db.Integer, nullable=False) + # MEDIUMTEXT on MySQL (a full scope with comments can exceed 64 KB TEXT). + manifestjson = db.Column(db.Text(length=16777215), nullable=False) + publishedat = db.Column(db.DateTime, nullable=False) + publishedby = db.Column(db.Integer, nullable=True) # soft ref to users + iscurrent = db.Column(db.Boolean, nullable=False, default=False) + notes = db.Column(db.String(255), nullable=True) + + scope = db.relationship('ManifestScope', back_populates='publishedversions') + + __table_args__ = ( + db.UniqueConstraint('scopeid', 'versionnumber', + name='uq_published_scope_version'), + ) + + +class ManifestPayload(db.Model): + """Inline payload bytes for payloadsource='inline' (small scripts/configs).""" + __tablename__ = 'manifestpayloads' + + payloadid = db.Column(db.Integer, primary_key=True) + entryid = db.Column( + db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'), + nullable=False, index=True) + filename = db.Column(db.String(255), nullable=False) + contenttype = db.Column(db.String(128), nullable=True) + payloadbytes = db.Column(db.LargeBinary(length=16777215), nullable=False) + payloadsha256 = db.Column(db.String(64), nullable=False) + uploadedat = db.Column(db.DateTime, nullable=False) + + +class ManifestEnforcementReport(db.Model): + """One enforcement cycle reported by a PC (observed state). + + Each cycle the client POSTs its result for a scope: the published version it + actually applied (so shopdb knows whether the PC RECEIVED the latest update), + the enforcer version, and the installed/skipped/failed/filtered counts. The + latest report per (hostname, scopename, phase) carries `iscurrent`; older + ones are history. Pairs desired state (the manifest) with observed state. + """ + __tablename__ = 'manifestenforcementreports' + + reportid = db.Column(db.Integer, primary_key=True) + hostname = db.Column(db.String(100), nullable=False, index=True) + scopename = db.Column(db.String(64), nullable=False) + phase = db.Column(db.String(16), nullable=False, default='runtime') + # Published version the client actually ran; compare to the scope's current + # published version to see whether this PC received the latest manifest. + appliedversion = db.Column(db.Integer, nullable=True) + enforcerversion = db.Column(db.String(20), nullable=True) + installedcount = db.Column(db.Integer, nullable=False, default=0) + skippedcount = db.Column(db.Integer, nullable=False, default=0) + failedcount = db.Column(db.Integer, nullable=False, default=0) + filteredcount = db.Column(db.Integer, nullable=False, default=0) + # 'ok' | 'failed' (any failure) | 'selfhealed' (drift corrected, no failure). + status = db.Column(db.String(16), nullable=False, default='ok') + lastcheckin = db.Column(db.DateTime, nullable=True) # PC-reported time + receivedat = db.Column(db.DateTime, nullable=False) # server time + iscurrent = db.Column(db.Boolean, nullable=False, default=True, index=True) + + results = db.relationship( + 'ManifestEnforcementResult', back_populates='report', + cascade='all, delete-orphan', lazy='selectin') + + __table_args__ = ( + db.Index('idx_report_host_scope', 'hostname', 'scopename', 'phase'), + ) + + +class ManifestEnforcementResult(db.Model): + """One entry's outcome within an enforcement cycle (self-heal detail).""" + __tablename__ = 'manifestenforcementresults' + + resultid = db.Column(db.Integer, primary_key=True) + reportid = db.Column( + db.Integer, + db.ForeignKey('manifestenforcementreports.reportid', ondelete='CASCADE'), + nullable=False, index=True) + entryname = db.Column(db.String(128), nullable=False) + # 'installed' (action fired - a self-heal when it should already be present), + # 'skipped' (detected present), 'failed', 'filtered'. + action = db.Column(db.String(16), nullable=False) + # True when this install was a drift correction (self-heal), not a first + # install. Client-supplied; defaults to whether the action installed. + selfhealed = db.Column(db.Boolean, nullable=False, default=False) + exitcode = db.Column(db.Integer, nullable=True) + message = db.Column(db.Text, nullable=True) # warning / error text + + report = db.relationship('ManifestEnforcementReport', back_populates='results') + + +class PcTypeAlias(db.Model): + """Mirror of the engine lib's PCTypes alias graph (Install-FromManifest.ps1). + + Rows sharing an `aliasgroup` are one alias set. Server-side resolve/validate + only; the engine lib stays the single source of truth (never inverted). + """ + __tablename__ = 'pctypealiases' + + aliasid = db.Column(db.Integer, primary_key=True) + aliasgroup = db.Column(db.Integer, nullable=False, index=True) + aliasname = db.Column(db.String(64), nullable=False) + + __table_args__ = ( + db.UniqueConstraint('aliasgroup', 'aliasname', name='uq_alias_group_name'), + ) diff --git a/plugins/geenforce/parity.py b/plugins/geenforce/parity.py new file mode 100644 index 00000000..ba7ca5ad --- /dev/null +++ b/plugins/geenforce/parity.py @@ -0,0 +1,106 @@ +"""P1 behavioral-parity harness (Gate A). + +Proves the model is lossless WITHOUT byte-diffing: for each manifest, import it +into in-memory rows and render it back out, then check that + + 1. every entry is field-for-field identical (engine-relevant fields only, + dropping _comment and key order), in the same order, AND + 2. the same entries fire in the same order for a set of machine-profile + fixtures, using the same filter logic the engine uses (filters.py). + +Runs with no database (build_scope produces detached ORM objects whose +relationship lists are read directly), so it is cheap to re-run and wrap in CI. +Output is one readable line per scope. +""" + +import json +import os + +from .importer import build_scope +from .serializer import scope_to_manifest, canonical_entry +from .filters import applicable_entry_names + +_FIXTURES_PATH = os.path.join(os.path.dirname(__file__), 'parityfixtures.json') + + +def load_fixtures(): + with open(_FIXTURES_PATH) as handle: + return json.load(handle) + + +def check_scope(scopename, phase, original, fixtures): + """Compare an original manifest against its import+export round-trip.""" + rebuilt = scope_to_manifest(build_scope(scopename, phase, original)) + orig_apps = original.get('Applications') or [] + rebuilt_apps = rebuilt.get('Applications') or [] + + # Check 0: scope-level fields (Version, Site) round-trip. + firstdiff = None + for key in ('Version', 'Site'): + if str(original.get(key) or '') != str(rebuilt.get(key) or ''): + firstdiff = (f'scope field {key}: ' + f'{original.get(key)!r} vs {rebuilt.get(key)!r}') + break + + scope_ok = firstdiff is None + + # Check 1: field-identical, order-preserving. + identical = 0 + if firstdiff is None and len(orig_apps) != len(rebuilt_apps): + firstdiff = (f'entry count {len(orig_apps)} vs {len(rebuilt_apps)}') + for i in range(min(len(orig_apps), len(rebuilt_apps))): + co = canonical_entry(orig_apps[i]) + cr = canonical_entry(rebuilt_apps[i]) + if co == cr: + identical += 1 + elif firstdiff is None: + name = orig_apps[i].get('Name', f'#{i}') + diffkeys = sorted( + k for k in set(co) | set(cr) if co.get(k) != cr.get(k)) + firstdiff = f'entry "{name}" differs on {diffkeys}' + + # Check 2: same entries fire in the same order for each profile. Inject the + # scope phase so PCTypesStrict is honored only for preinstall (see filters). + profiles_same = 0 + for base_profile in fixtures: + profile = {**base_profile, 'phase': phase} + if (applicable_entry_names(orig_apps, profile) + == applicable_entry_names(rebuilt_apps, profile)): + profiles_same += 1 + elif firstdiff is None: + firstdiff = f'filter mismatch for profile {profile.get("label")}' + + passed = (scope_ok + and identical == len(orig_apps) == len(rebuilt_apps) + and profiles_same == len(fixtures)) + return { + 'scopename': scopename, + 'phase': phase, + 'entries_total': len(orig_apps), + 'entries_identical': identical, + 'profiles_total': len(fixtures), + 'profiles_same': profiles_same, + 'passed': passed, + 'firstdiff': firstdiff, + } + + +def run_parity(manifests, fixtures=None): + """Run parity over a list of (scopename, phase, manifest_dict). Returns + (results, all_passed).""" + fixtures = fixtures if fixtures is not None else load_fixtures() + results = [check_scope(name, phase, manifest, fixtures) + for name, phase, manifest in manifests] + return results, all(r['passed'] for r in results) + + +def format_result(result): + """One human-readable line for a scope result.""" + status = 'PASS' if result['passed'] else 'FAIL' + line = (f"scope {result['scopename']:<28} " + f"entries {result['entries_identical']}/{result['entries_total']} identical " + f"profiles {result['profiles_same']}/{result['profiles_total']} same-fire " + f"{status}") + if not result['passed'] and result['firstdiff']: + line += f"\n -> {result['firstdiff']}" + return line diff --git a/plugins/geenforce/parityfixtures.json b/plugins/geenforce/parityfixtures.json new file mode 100644 index 00000000..2ec3045b --- /dev/null +++ b/plugins/geenforce/parityfixtures.json @@ -0,0 +1,20 @@ +[ + {"label": "collections-okuma-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC0615", "machinenumber": "3201", "cmmversion": ""}, + {"label": "collections-mtconnect-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC7501", "machinenumber": "7501", "cmmversion": ""}, + {"label": "collections-other-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC0001", "machinenumber": "0001", "cmmversion": ""}, + {"label": "nocollections", "pctype": "gea-shopfloor-nocollections", "subtype": "", "hostname": "WJPC2000", "machinenumber": "", "cmmversion": ""}, + {"label": "common-timeclock", "pctype": "gea-shopfloor-common", "subtype": "", "hostname": "WJPC3000", "machinenumber": "", "cmmversion": ""}, + {"label": "cmm-2016", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM01", "machinenumber": "9101", "cmmversion": "2016"}, + {"label": "cmm-2019", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM02", "machinenumber": "9102", "cmmversion": "2019"}, + {"label": "cmm-2026", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM11", "machinenumber": "9111", "cmmversion": "2026"}, + {"label": "cmm-noversion-legacy", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM99", "machinenumber": "9199", "cmmversion": ""}, + {"label": "keyence-vr6000", "pctype": "Keyence", "subtype": "vr6000", "hostname": "WJKEY01", "machinenumber": "8001", "cmmversion": ""}, + {"label": "keyence-vr3000", "pctype": "Keyence", "subtype": "vr3000", "hostname": "WJKEY02", "machinenumber": "8002", "cmmversion": ""}, + {"label": "genspect", "pctype": "gea-shopfloor-genspect", "subtype": "", "hostname": "WJGEN01", "machinenumber": "8100", "cmmversion": ""}, + {"label": "heattreat", "pctype": "gea-shopfloor-heattreat", "subtype": "", "hostname": "WJHT01", "machinenumber": "8200", "cmmversion": ""}, + {"label": "waxtrace", "pctype": "gea-shopfloor-waxtrace", "subtype": "", "hostname": "WJWAX01", "machinenumber": "8300", "cmmversion": ""}, + {"label": "partmarker", "pctype": "gea-shopfloor-partmarker", "subtype": "", "hostname": "WJPM01", "machinenumber": "8400", "cmmversion": ""}, + {"label": "display", "pctype": "gea-shopfloor-display", "subtype": "", "hostname": "WJDISP01", "machinenumber": "", "cmmversion": ""}, + {"label": "legacy-standard-machine", "pctype": "Standard", "subtype": "Machine", "hostname": "WJLEG01", "machinenumber": "3202", "cmmversion": ""}, + {"label": "legacy-cmm-name", "pctype": "CMM", "subtype": "", "hostname": "WJLEG02", "machinenumber": "9102", "cmmversion": "2019"} +] diff --git a/plugins/geenforce/plugin.py b/plugins/geenforce/plugin.py new file mode 100644 index 00000000..0732f32a --- /dev/null +++ b/plugins/geenforce/plugin.py @@ -0,0 +1,194 @@ +"""GE-Enforce manifest-store plugin. + +Owns the imaging-PC-type scopes and their install manifests as shopdb data +(see the ge-enforce-plugin proposal in the project wiki). This is the P0/P1 foundation: models, +the alias-graph seed, and the client-facing manifest endpoint. Authoring UI and +client cutover come in later phases. +""" + +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 shopdb.api import db + +from .api import geenforce_bp +from .models import ( + ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname, + ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess, + ManifestPublishedVersion, ManifestPayload, ManifestEnforcementReport, + ManifestEnforcementResult, PcTypeAlias, +) +from .filters import ALIAS_GROUPS + +logger = logging.getLogger(__name__) + + +class GeEnforcePlugin(BasePlugin): + """Desired-state manifest store for the GE-Enforce client.""" + + 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 handle: + return json.load(handle) + return {} + + @property + def meta(self) -> PluginMeta: + return PluginMeta( + name=self._manifest.get('name', 'geenforce'), + version=self._manifest.get('version', '0.1.0'), + description=self._manifest.get('description', 'GE-Enforce manifest store'), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.7.0,<1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/geenforce'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + return geenforce_bp + + def get_models(self) -> List[Type]: + return [ + ManifestScope, ManifestEntry, ManifestEntryPcType, + ManifestEntryHostname, ManifestEntryMachineNumber, + ManifestInUseCheck, ManifestInUseCheckProcess, + ManifestPublishedVersion, ManifestPayload, + ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias, + ] + + def get_permissions(self) -> List: + """RBAC permissions this plugin owns (edit vs ship are split).""" + return [ + ('geenforce.manage', 'Edit imaging PC types and manifest drafts', + 'geenforce'), + ('geenforce.publish', 'Publish, roll back, and export manifests', + 'geenforce'), + ('geenforce.fetch', 'Fetch published manifests (client service token)', + 'geenforce'), + ('geenforce.report', 'Report enforcement results (client service token)', + 'geenforce'), + ] + + def get_navigation_items(self) -> List[Dict]: + """Top-level sidebar section (GE-Enforce is a large operational surface, + not a mere setting). The tabbed shell hosts Manifests + Reports.""" + return [ + { + 'name': 'GE-Enforce', + 'icon': 'shield', + 'route': '/geenforce/manifests', + 'position': 46, + }, + ] + + def init_app(self, app: Flask, db_instance) -> None: + logger.info(f"GE-Enforce plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + with app.app_context(): + self._seed_aliases() + db.session.commit() + logger.info("GE-Enforce plugin installed") + + def _seed_aliases(self) -> None: + """Seed pctypealiases from the engine lib's alias graph (idempotent).""" + for group_index, group in enumerate(ALIAS_GROUPS): + for aliasname in group: + exists = PcTypeAlias.query.filter_by( + aliasgroup=group_index, aliasname=aliasname).first() + if not exists: + db.session.add(PcTypeAlias( + aliasgroup=group_index, aliasname=aliasname)) + + def get_cli_commands(self) -> List: + """CLI: parity (Gate A), import-share, export-share, publish.""" + + @click.group('geenforce') + def geenforce_cli(): + """GE-Enforce manifest-store commands.""" + + @geenforce_cli.command('parity') + @click.option('--shareroot', required=True, + help='GE-Enforce share root (contains common/, ' + 'gea-shopfloor-*/).') + @click.option('--preinstall', default=None, + help='Optional path to a preinstall.json to include.') + def parity_cmd(shareroot, preinstall): + """Prove import+export is behaviorally lossless (Gate A). DB-free.""" + from .importer import discover_share, load_manifest_file + from .parity import run_parity, format_result + + manifests = list(discover_share(shareroot)) + if preinstall: + manifests.append( + ('preinstall', 'preinstall', load_manifest_file(preinstall))) + results, ok = run_parity(manifests) + for result in results: + click.echo(format_result(result)) + click.echo(f"RESULT: {'PASS' if ok else 'FAIL'} " + f"({len(results)} scopes)") + raise SystemExit(0 if ok else 1) + + @geenforce_cli.command('import-share') + @click.option('--shareroot', required=True) + @click.option('--preinstall', default=None) + @click.option('--scope', default=None, + help='Import only this scope name (else all).') + def import_share_cmd(shareroot, preinstall, scope): + """Import on-share manifests into draft rows (idempotent rebuild).""" + from flask import current_app + from .importer import discover_share, load_manifest_file + from .service import replace_scope_draft + + with current_app.app_context(): + sources = list(discover_share(shareroot)) + if preinstall: + sources.append(('preinstall', 'preinstall', + load_manifest_file(preinstall))) + count = 0 + for name, phase, manifest in sources: + if scope and name != scope: + continue + replace_scope_draft(name, phase, manifest) + count += 1 + db.session.commit() + click.echo(f"Imported {count} scope(s).") + + @geenforce_cli.command('publish') + @click.argument('scopename') + @click.option('--phase', default='runtime') + @click.option('--notes', default=None) + def publish_cmd(scopename, phase, notes): + """Freeze the current draft of a scope into a published snapshot.""" + from flask import current_app + from .service import publish_scope + + with current_app.app_context(): + version = publish_scope(scopename, phase, notes=notes) + db.session.commit() + click.echo(f"Published {scopename}/{phase} as v{version}.") + + @geenforce_cli.command('export-share') + @click.argument('scopename') + @click.option('--shareroot', required=True) + @click.option('--phase', default='runtime') + def export_share_cmd(scopename, shareroot, phase): + """Write a scope's published JSON to the share (with history backup).""" + from flask import current_app + from .service import export_scope_to_share + + with current_app.app_context(): + path = export_scope_to_share(scopename, phase, shareroot) + click.echo(f"Exported to {path}.") + + return [geenforce_cli] diff --git a/plugins/geenforce/serializer.py b/plugins/geenforce/serializer.py new file mode 100644 index 00000000..7fe40735 --- /dev/null +++ b/plugins/geenforce/serializer.py @@ -0,0 +1,133 @@ +"""Rebuild manifest JSON from DB rows, and canonicalize entries for parity. + +`scope_to_manifest` renders a ManifestScope back into the exact JSON structure +GE-Enforce consumes (Version / _comment / Applications, plus Site for +preinstall). `canonical_entry` reduces an Applications[] entry (from either the +on-share file or a rebuilt scope) to only the fields the engine reads, dropping +`_comment` and key order, so the parity harness can compare behavior not bytes. +""" + +import json + +# Scalar entry fields: model attribute -> manifest key. Emitted only when set, +# so a rebuilt entry has the same sparse key presence as the original. +_SCALAR_FIELDS = [ + ('name', 'Name'), + ('entrytype', 'Type'), + ('installer', 'Installer'), + ('installargs', 'InstallArgs'), + ('scriptpath', 'Script'), + ('scriptargs', 'Args'), + ('sourcepath', 'Source'), + ('destination', 'Destination'), + ('regpath', 'RegPath'), + ('regname', 'RegName'), + ('regtype', 'RegType'), + ('detectionmethod', 'DetectionMethod'), + ('detectionpath', 'DetectionPath'), + ('detectionname', 'DetectionName'), + ('detectionvalue', 'DetectionValue'), + ('detectionpattern', 'DetectionPattern'), + ('cmmversion', '_CmmVersion'), + ('logfile', 'LogFile'), + ('waittimeoutsec', 'WaitTimeoutSec'), + ('applymode', 'ApplyMode'), + ('updatewindow', 'UpdateWindow'), +] + +# Boolean flags emitted only when True (matches how preinstall.json carries them). +_BOOL_FLAGS = [ + ('preenrollment', 'PreEnrollment'), + ('killafterdetection', 'KillAfterDetection'), + ('pctypesstrict', 'PCTypesStrict'), +] + +# The complete set of engine-relevant keys the parity check compares. +ENGINE_KEYS = ( + [mk for _, mk in _SCALAR_FIELDS] + + [mk for _, mk in _BOOL_FLAGS] + + ['PCTypes', 'TargetHostnames', 'TargetMachineNumbers', 'RegValue', 'InUseCheck'] +) + + +def entry_to_dict(entry): + """Render a ManifestEntry model back into a manifest Applications[] entry.""" + result = {} + if entry.comment: + result['_comment'] = entry.comment + for attr, key in _SCALAR_FIELDS: + value = getattr(entry, attr) + if value is not None and value != '': + result[key] = value + # RegValue: stored as a raw JSON literal, reconstitute its real type. + if entry.regvalue is not None: + result['RegValue'] = json.loads(entry.regvalue) + # Multi-value filters (only when present). + if entry.pctypes: + result['PCTypes'] = [p.pctypevalue for p in entry.pctypes] + if entry.hostnames: + result['TargetHostnames'] = [h.hostnamepattern for h in entry.hostnames] + if entry.machinenumbers: + result['TargetMachineNumbers'] = [m.machinenumber + for m in entry.machinenumbers] + for attr, key in _BOOL_FLAGS: + if getattr(entry, attr): + result[key] = True + # InUseCheck (nested object + Processes[]). + if entry.inusecheck: + procs = [] + for proc in entry.inusecheck.processes: + pd = {'Name': proc.processname} + if proc.exepath is not None: + pd['ExePath'] = proc.exepath + if proc.gracefulclosetimeoutsec is not None: + pd['GracefulCloseTimeoutSec'] = proc.gracefulclosetimeoutsec + procs.append(pd) + result['InUseCheck'] = { + 'Behavior': entry.inusecheck.behavior, + 'Processes': procs, + } + return result + + +def scope_to_manifest(scope): + """Render a ManifestScope back into a full manifest.json structure.""" + manifest = {'Version': scope.manifestversion} + if scope.topcomment: + manifest['_comment'] = scope.topcomment + if scope.site: + manifest['Site'] = scope.site + manifest['Applications'] = [entry_to_dict(e) for e in scope.entries] + return manifest + + +def scope_to_json(scope, indent=2): + """The serialized JSON text a client/export would receive.""" + return json.dumps(scope_to_manifest(scope), indent=indent) + + +def canonical_entry(entry_dict): + """Reduce an Applications[] entry to engine-relevant fields for parity. + + Drops `_comment` and key order. Normalizes InUseCheck and the process list + so two entries that behave identically compare equal regardless of source. + """ + canon = {} + for key in ENGINE_KEYS: + if key == 'InUseCheck': + continue + if key in entry_dict and entry_dict[key] is not None and entry_dict[key] != '': + canon[key] = entry_dict[key] + inuse = entry_dict.get('InUseCheck') + if inuse: + procs = [] + for proc in inuse.get('Processes', []): + pd = {'Name': proc.get('Name')} + if proc.get('ExePath') is not None: + pd['ExePath'] = proc.get('ExePath') + if proc.get('GracefulCloseTimeoutSec') is not None: + pd['GracefulCloseTimeoutSec'] = proc.get('GracefulCloseTimeoutSec') + procs.append(pd) + canon['InUseCheck'] = {'Behavior': inuse.get('Behavior'), + 'Processes': procs} + return canon diff --git a/plugins/geenforce/service.py b/plugins/geenforce/service.py new file mode 100644 index 00000000..f004bd2a --- /dev/null +++ b/plugins/geenforce/service.py @@ -0,0 +1,343 @@ +"""DB-touching operations for the manifest store. + +Kept out of the CLI and routes so both share one implementation: +- `replace_scope_draft` imports/re-imports a scope's DRAFT entries WITHOUT + touching its published-version history (re-import is idempotent + safe). +- `publish_scope` freezes the current draft into a new immutable snapshot. +- `rollback_scope` / `export_scope_to_share` round out the publish lifecycle. +""" + +import hashlib +import os +import tempfile +from datetime import datetime, timezone + +from sqlalchemy import func + +from shopdb.api import db, Application + +from .models import ( + ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport, + ManifestEnforcementResult, ManifestPayload, +) +from .importer import build_entry +from .serializer import scope_to_json + + +def _utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _installed_app_model(): + """Lazily import the computers plugin's ComputerInstalledApp. + + The computers plugin is optional; importing it lazily (not at module load) + keeps geenforce usable when computers is absent or disabled. Mirrors + collector._computer_models. Returns the model class, or None if absent. + """ + try: + from plugins.computers.models import ComputerInstalledApp + return ComputerInstalledApp + except ImportError: + return None + + +def replace_scope_draft(scopename, phase, manifest): + """Create/refresh a scope and REPLACE its draft entries. Published versions + are left untouched. Returns the scope (uncommitted).""" + scope = ManifestScope.query.filter_by( + scopename=scopename, phase=phase).first() + if not scope: + scope = ManifestScope(scopename=scopename, phase=phase) + db.session.add(scope) + scope.manifestversion = str(manifest.get('Version', '1.0')) + scope.topcomment = manifest.get('_comment') + scope.site = manifest.get('Site') + scope.iscommon = (scopename == 'common') + + for entry in list(scope.entries): + db.session.delete(entry) + db.session.flush() + + for i, entry_dict in enumerate(manifest.get('Applications') or []): + scope.entries.append(build_entry(entry_dict, i)) + return scope + + +def publish_scope(scopename, phase, notes=None, publishedby=None): + """Freeze the current draft into a new published snapshot. Returns the + version number (uncommitted).""" + scope = ManifestScope.query.filter_by( + scopename=scopename, phase=phase).first() + if not scope: + raise ValueError(f'No scope {scopename}/{phase}') + + text = scope_to_json(scope) + maxversion = db.session.query( + func.max(ManifestPublishedVersion.versionnumber) + ).filter_by(scopeid=scope.scopeid).scalar() or 0 + + ManifestPublishedVersion.query.filter_by( + scopeid=scope.scopeid, iscurrent=True + ).update({'iscurrent': False}) + + db.session.add(ManifestPublishedVersion( + scopeid=scope.scopeid, + versionnumber=maxversion + 1, + manifestjson=text, + publishedat=_utcnow(), + publishedby=publishedby, + iscurrent=True, + notes=notes)) + return maxversion + 1 + + +def rollback_scope(scopename, phase, versionnumber): + """Make an older published version current again (uncommitted).""" + scope = ManifestScope.query.filter_by( + scopename=scopename, phase=phase).first() + if not scope: + raise ValueError(f'No scope {scopename}/{phase}') + target = ManifestPublishedVersion.query.filter_by( + scopeid=scope.scopeid, versionnumber=versionnumber).first() + if not target: + raise ValueError(f'No version {versionnumber} for {scopename}') + ManifestPublishedVersion.query.filter_by( + scopeid=scope.scopeid, iscurrent=True + ).update({'iscurrent': False}) + target.iscurrent = True + return versionnumber + + +def record_enforcement_report(payload): + """Record one PC's enforcement cycle (observed state). Upserts the latest + report per (hostname, scopename, phase) and keeps prior ones as history. + + Payload (all but hostname optional): + hostname, scopename, phase, appliedversion, enforcerversion, lastcheckin, + counts {installed, skipped, failed, filtered}, + results [{name, action, selfhealed, exitcode, message}] + + Returns the new ManifestEnforcementReport (uncommitted). + """ + hostname = (payload.get('hostname') or '').strip() + if not hostname: + raise ValueError('hostname is required') + scopename = (payload.get('scopename') or '').strip() + phase = (payload.get('phase') or 'runtime').strip() + counts = payload.get('counts') or {} + results = payload.get('results') or [] + # Guard wrong JSON shapes: a bad type must be a clean 400, not a 500 from a + # later .get()/iteration (the route maps ValueError to 400). + if not isinstance(counts, dict): + raise ValueError('counts must be an object') + if not isinstance(results, list): + raise ValueError('results must be a list') + + failed = int(counts.get('failed', 0)) + installed = int(counts.get('installed', 0)) + # Derive status from EXPLICIT self-heal flags only, never the raw installed + # count: every healthy cycle runs Always/no-detection scripts (asset report, + # VNC firewall, EventSaver) that the engine counts as "installed", so keying + # self-heal off installed>0 would mark the common scope selfhealed forever + # and make 'ok' unreachable. A drift correction is one the client flags. + if failed > 0: + status = 'failed' + elif any(r.get('selfhealed') for r in results): + status = 'selfhealed' + else: + status = 'ok' + + # Demote the prior current report for this host+scope. Hostname match is + # case-insensitive to match the ilike read path - otherwise a PC reporting + # its name in different casing would leave two iscurrent rows. + ManifestEnforcementReport.query.filter( + func.lower(ManifestEnforcementReport.hostname) == hostname.lower(), + ManifestEnforcementReport.scopename == scopename, + ManifestEnforcementReport.phase == phase, + ManifestEnforcementReport.iscurrent == True + ).update({'iscurrent': False}, synchronize_session=False) + + report = ManifestEnforcementReport( + hostname=hostname, + scopename=scopename, + phase=phase, + appliedversion=payload.get('appliedversion'), + enforcerversion=payload.get('enforcerversion'), + installedcount=installed, + skippedcount=int(counts.get('skipped', 0)), + failedcount=failed, + filteredcount=int(counts.get('filtered', 0)), + status=status, + lastcheckin=_parse_dt(payload.get('lastcheckin')), + receivedat=_utcnow(), + iscurrent=True) + for item in results: + report.results.append(ManifestEnforcementResult( + entryname=item.get('name', ''), + action=item.get('action', ''), + # Explicit flag only; do NOT infer from action == 'installed' + # (Always/no-detection scripts install every cycle without being a + # drift correction). + selfhealed=bool(item.get('selfhealed', False)), + exitcode=item.get('exitcode'), + message=item.get('message'))) + db.session.add(report) + return report + + +def _parse_dt(value): + if not value: + return None + try: + return datetime.fromisoformat(str(value).replace('Z', '+00:00')).replace( + tzinfo=None) + except (ValueError, TypeError): + return None + + +def compliance_for_scope(scope): + """Fleet-install coverage for a scope's app-linked entries. + + One row per ManifestEntry that carries a curated appid (unlinked entries + are skipped), ordered by sortorder. Counts come from the computers plugin's + ComputerInstalledApp (active rows only, one row per PC per app). Degrades + gracefully with null counts when the computers plugin is absent. + + Returns the response data dict (scopeid/scopename/phase/computersplugin/rows). + """ + linked = [e for e in sorted(scope.entries, key=lambda e: e.sortorder) + if e.appid is not None] + installedmodel = _installed_app_model() + computerspresent = installedmodel is not None + + # One grouped query for the whole scope's appid set (fleet is tiny). + installedbyapp = {} + if computerspresent and linked: + appids = {e.appid for e in linked} + counted = db.session.query( + installedmodel.appid, func.count(installedmodel.id) + ).filter( + installedmodel.isactive == True, + installedmodel.appid.in_(appids), + ).group_by(installedmodel.appid).all() + installedbyapp = {appid: count for appid, count in counted} + + rows = [] + for entry in linked: + app = db.session.get(Application, entry.appid) + appname = app.appname if app else None + expectedversion = (entry.detectionvalue + if entry.detectionmethod == 'FileVersion' else None) + + if not computerspresent: + installedcount = None + versionmatchcount = None + coveragenote = ('computers plugin not installed; install counts ' + 'unavailable') + else: + installedcount = installedbyapp.get(entry.appid, 0) + if installedcount == 0: + versionmatchcount = None if expectedversion is None else 0 + coveragenote = 'not installed on any collected PC' + elif expectedversion is None: + versionmatchcount = None + method = entry.detectionmethod or 'none' + coveragenote = (f'installed on {installedcount} PC(s); no ' + f'version target (detection is {method})') + else: + versionmatchcount = db.session.query( + func.count(installedmodel.id) + ).filter( + installedmodel.isactive == True, + installedmodel.appid == entry.appid, + installedmodel.installedversion == expectedversion, + ).scalar() or 0 + coveragenote = (f'{versionmatchcount} of {installedcount} ' + 'collected PCs on the expected version') + + rows.append({ + 'entryid': entry.entryid, + 'entryname': entry.name, + 'appid': entry.appid, + 'appname': appname, + 'expectedversion': expectedversion, + 'installedcount': installedcount, + 'versionmatchcount': versionmatchcount, + 'coveragenote': coveragenote, + }) + + return { + 'scopeid': scope.scopeid, + 'scopename': scope.scopename, + 'phase': scope.phase, + 'computersplugin': computerspresent, + 'rows': rows, + } + + +def store_inline_payload(entry, filename, contenttype, rawbytes): + """Replace the entry's inline payload with these bytes (uncommitted). + + Computes payloadsha256, upserts the single ManifestPayload row (the table + has no unique constraint on entryid, so the one-inline-payload-per-entry + rule is enforced here), and points the entry at it (payloadsource='inline'). + Returns the ManifestPayload. + """ + sha = hashlib.sha256(rawbytes).hexdigest() + ManifestPayload.query.filter_by(entryid=entry.entryid).delete() + db.session.flush() + payload = ManifestPayload( + entryid=entry.entryid, + filename=filename, + contenttype=contenttype, + payloadbytes=rawbytes, + payloadsha256=sha, + uploadedat=_utcnow()) + db.session.add(payload) + entry.payloadsource = 'inline' + entry.payloadref = filename + entry.payloadsha256 = sha + return payload + + +def export_scope_to_share(scopename, phase, shareroot): + """Write a scope's current published JSON to the share, backing up the old + file to _meta/history first. Returns the written path.""" + scope = ManifestScope.query.filter_by( + scopename=scopename, phase=phase).first() + if not scope: + raise ValueError(f'No scope {scopename}/{phase}') + published = scope.publishedversions.filter_by(iscurrent=True).first() + if not published: + raise ValueError(f'{scopename} has no published version') + + if phase == 'preinstall': + target = os.path.join(shareroot, 'preinstall.json') + else: + target = os.path.join(shareroot, scopename, 'manifest.json') + os.makedirs(os.path.dirname(target), exist_ok=True) + + if os.path.isfile(target): + historydir = os.path.join(shareroot, '_meta', 'history') + os.makedirs(historydir, exist_ok=True) + stamp = _utcnow().strftime('%Y%m%d-%H%M%S') + with open(target) as src: + old = src.read() + with open(os.path.join(historydir, f'{stamp}-{scopename}.json'), 'w') as dst: + dst.write(old) + + # Atomic write: a partial/failed write must never leave the live on-share + # manifest (which every PC reads) truncated. Write a temp file in the same + # directory, then rename over the target. + targetdir = os.path.dirname(target) + fd, tmppath = tempfile.mkstemp(dir=targetdir, suffix='.tmp') + try: + with os.fdopen(fd, 'w') as handle: + handle.write(published.manifestjson) + os.replace(tmppath, target) + except Exception: + if os.path.exists(tmppath): + os.remove(tmppath) + raise + return target diff --git a/plugins/knowledgebase/__init__.py b/plugins/knowledgebase/__init__.py new file mode 100644 index 00000000..b4a73f0c --- /dev/null +++ b/plugins/knowledgebase/__init__.py @@ -0,0 +1,5 @@ +"""Knowledge Base plugin package.""" + +from .plugin import KnowledgeBasePlugin + +__all__ = ['KnowledgeBasePlugin'] diff --git a/plugins/knowledgebase/api/__init__.py b/plugins/knowledgebase/api/__init__.py new file mode 100644 index 00000000..afcaaca9 --- /dev/null +++ b/plugins/knowledgebase/api/__init__.py @@ -0,0 +1,5 @@ +"""Knowledge Base plugin API.""" + +from .routes import knowledgebase_bp + +__all__ = ['knowledgebase_bp'] diff --git a/plugins/knowledgebase/api/routes.py b/plugins/knowledgebase/api/routes.py new file mode 100644 index 00000000..fcd35fc2 --- /dev/null +++ b/plugins/knowledgebase/api/routes.py @@ -0,0 +1,224 @@ +"""Knowledge Base API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import ( + db, + Application, + success_response, + error_response, + paginated_response, + ErrorCodes, + get_pagination_params, + paginate_query, +) + +from ..models import KnowledgeBase + +from shopdb.api import require_permission, apply_import_timestamps + +knowledgebase_bp = Blueprint('knowledgebase', __name__) + + +@knowledgebase_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_articles(): + """List all knowledge base articles.""" + page, per_page = get_pagination_params(request) + + query = KnowledgeBase.query.filter_by(isactive=True) + + # Search + if search := request.args.get('search'): + query = query.filter( + db.or_( + KnowledgeBase.shortdescription.ilike(f'%{search}%'), + KnowledgeBase.keywords.ilike(f'%{search}%') + ) + ) + + # Filter by topic/application + if appid := request.args.get('appid'): + query = query.filter(KnowledgeBase.appid == int(appid)) + + # Exact-match natural-key lookups for idempotent import. linkurl is the + # stable natural key; shortdescription (the title) is offered as a fallback. + if exactlinkurl := request.args.get('linkurl'): + query = query.filter(KnowledgeBase.linkurl == exactlinkurl) + if exacttitle := request.args.get('shortdescription'): + query = query.filter(KnowledgeBase.shortdescription == exacttitle) + + # Sort options + sort = request.args.get('sort', 'clicks') + order = request.args.get('order', 'desc') + + if sort == 'clicks': + query = query.order_by( + KnowledgeBase.clicks.desc() if order == 'desc' else KnowledgeBase.clicks.asc(), + KnowledgeBase.lastupdated.desc() + ) + elif sort == 'topic': + query = query.join(Application).order_by( + Application.appname.desc() if order == 'desc' else Application.appname.asc() + ) + elif sort == 'description': + query = query.order_by( + KnowledgeBase.shortdescription.desc() if order == 'desc' else KnowledgeBase.shortdescription.asc() + ) + elif sort == 'lastupdated': + query = query.order_by( + KnowledgeBase.lastupdated.desc() if order == 'desc' else KnowledgeBase.lastupdated.asc() + ) + else: + query = query.order_by(KnowledgeBase.clicks.desc()) + + items, total = paginate_query(query, page, per_page) + data = [] + for article in items: + article_dict = article.to_dict() + if article.application: + article_dict['application'] = { + 'appid': article.application.appid, + 'appname': article.application.appname + } + else: + article_dict['application'] = None + data.append(article_dict) + + return paginated_response(data, page, per_page, total) + + +@knowledgebase_bp.route('/stats', methods=['GET']) +@jwt_required(optional=True) +def get_stats(): + """Get knowledge base statistics.""" + total_clicks = db.session.query( + db.func.coalesce(db.func.sum(KnowledgeBase.clicks), 0) + ).filter(KnowledgeBase.isactive == True).scalar() + + total_articles = KnowledgeBase.query.filter_by(isactive=True).count() + + return success_response({ + 'totalclicks': int(total_clicks), + 'totalarticles': total_articles + }) + + +@knowledgebase_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_article(link_id: int): + """Get a single knowledge base article.""" + article = db.session.get(KnowledgeBase, link_id) + + if not article or not article.isactive: + return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) + + data = article.to_dict() + if article.application: + data['application'] = { + 'appid': article.application.appid, + 'appname': article.application.appname + } + else: + data['application'] = None + + return success_response(data) + + +@knowledgebase_bp.route('//click', methods=['POST']) +@jwt_required(optional=True) +def track_click(link_id: int): + """Increment click counter and return the URL to redirect to.""" + article = db.session.get(KnowledgeBase, link_id) + + if not article or not article.isactive: + return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) + + article.increment_clicks() + db.session.commit() + + return success_response({ + 'linkurl': article.linkurl, + 'clicks': article.clicks + }) + + +@knowledgebase_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('kb.create') +def create_article(): + """Create a new knowledge base article.""" + data = request.get_json() + + if not data or not data.get('shortdescription'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'shortdescription is required') + + if not data.get('linkurl'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'linkurl is required') + + # Validate application if provided + if data.get('appid'): + app = db.session.get(Application, data['appid']) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + article = KnowledgeBase( + shortdescription=data['shortdescription'], + linkurl=data['linkurl'], + appid=data.get('appid'), + keywords=data.get('keywords'), + clicks=0 + ) + + db.session.add(article) + apply_import_timestamps(article, data) + db.session.commit() + + return success_response(article.to_dict(), message='Article created', http_code=201) + + +@knowledgebase_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('kb.edit') +def update_article(link_id: int): + """Update a knowledge base article.""" + article = db.session.get(KnowledgeBase, link_id) + + if not article: + return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Validate application if being changed + if 'appid' in data and data['appid']: + app = db.session.get(Application, data['appid']) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + fields = ['shortdescription', 'linkurl', 'appid', 'keywords', 'isactive'] + for key in fields: + if key in data: + setattr(article, key, data[key]) + + apply_import_timestamps(article, data) + db.session.commit() + return success_response(article.to_dict(), message='Article updated') + + +@knowledgebase_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('kb.delete') +def delete_article(link_id: int): + """Delete (deactivate) a knowledge base article.""" + article = db.session.get(KnowledgeBase, link_id) + + if not article: + return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) + + article.isactive = False + db.session.commit() + + return success_response(message='Article deleted') diff --git a/plugins/knowledgebase/manifest.json b/plugins/knowledgebase/manifest.json new file mode 100644 index 00000000..46dfb510 --- /dev/null +++ b/plugins/knowledgebase/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "knowledgebase", + "display_name": "Knowledge Base", + "version": "1.0.0", + "description": "Knowledge Base articles linking to external resources", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/knowledgebase", + "provides": { + "features": ["knowledgebase"] + } +} diff --git a/plugins/knowledgebase/migrations/env.py b/plugins/knowledgebase/migrations/env.py new file mode 100644 index 00000000..b230fff1 --- /dev/null +++ b/plugins/knowledgebase/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the knowledgebase 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_knowledgebase. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'knowledgebase' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/knowledgebase/migrations/script.py.mako b/plugins/knowledgebase/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/knowledgebase/migrations/script.py.mako @@ -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"} diff --git a/plugins/knowledgebase/migrations/versions/0001_knowledgebase_anchor.py b/plugins/knowledgebase/migrations/versions/0001_knowledgebase_anchor.py new file mode 100644 index 00000000..143e3209 --- /dev/null +++ b/plugins/knowledgebase/migrations/versions/0001_knowledgebase_anchor.py @@ -0,0 +1,28 @@ +"""knowledgebase 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_knowledgebase. From this +anchor forward, new knowledgebase 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 = 'knowledgebase0001anchor' +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 diff --git a/plugins/knowledgebase/models/__init__.py b/plugins/knowledgebase/models/__init__.py new file mode 100644 index 00000000..761162a9 --- /dev/null +++ b/plugins/knowledgebase/models/__init__.py @@ -0,0 +1,5 @@ +"""Knowledge Base plugin models.""" + +from .knowledgebase import KnowledgeBase + +__all__ = ['KnowledgeBase'] diff --git a/plugins/knowledgebase/models/knowledgebase.py b/plugins/knowledgebase/models/knowledgebase.py new file mode 100644 index 00000000..b37b95e9 --- /dev/null +++ b/plugins/knowledgebase/models/knowledgebase.py @@ -0,0 +1,40 @@ +"""Knowledge Base model. + +Non-asset plugin model. The `knowledgebase` table lives in the core Alembic +chain (bundled-plugin schema is folded into core, ADR-004); this class just +maps it and is registered via the plugin's get_models hook. The appid FK +references the core applications table by name, which resolves at mapper config +time without importing the core model. +""" + +from datetime import datetime, timezone + +from shopdb.api import db, BaseModel + + +def _utcnow(): + # naive UTC to match the other DB DateTime columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class KnowledgeBase(BaseModel): + """Knowledge Base article linking to external resources.""" + __tablename__ = 'knowledgebase' + + linkid = db.Column(db.Integer, primary_key=True) + appid = db.Column(db.Integer, db.ForeignKey('applications.appid')) + shortdescription = db.Column(db.String(500), nullable=False) + linkurl = db.Column(db.String(2000)) + keywords = db.Column(db.String(500)) + clicks = db.Column(db.Integer, default=0) + lastupdated = db.Column(db.DateTime, default=_utcnow, onupdate=_utcnow) + + # Relationship to the core Application model (resolved by class name). + application = db.relationship('Application', backref=db.backref('knowledgebase_articles', lazy='dynamic')) + + def __repr__(self): + return f"" + + def increment_clicks(self): + """Increment click counter.""" + self.clicks = (self.clicks or 0) + 1 diff --git a/plugins/knowledgebase/plugin.py b/plugins/knowledgebase/plugin.py new file mode 100644 index 00000000..85fd73f6 --- /dev/null +++ b/plugins/knowledgebase/plugin.py @@ -0,0 +1,83 @@ +"""Knowledge Base plugin main class. + +Reference NON-asset plugin: contributes a model + blueprint + nav item but no +AssetType (it is not an asset). Mirrors the notifications plugin. +""" + +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 .models import KnowledgeBase +from .api import knowledgebase_bp + +logger = logging.getLogger(__name__) + + +class KnowledgeBasePlugin(BasePlugin): + """Knowledge Base plugin - articles linking to external resources.""" + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + 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 plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'knowledgebase'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Knowledge Base articles linking to external resources' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/knowledgebase'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return knowledgebase_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [KnowledgeBase] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Knowledge Base plugin initialized (v{self.meta.version})") + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Knowledge Base', + 'icon': 'book-open', + 'route': '/knowledgebase', + 'position': 35, + 'section': 'information', + }, + ] + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('kb.view', 'View knowledge base', 'knowledgebase'), + ('kb.create', 'Create KB articles', 'knowledgebase'), + ('kb.edit', 'Edit KB articles', 'knowledgebase'), + ('kb.delete', 'Delete KB articles', 'knowledgebase'), + ] diff --git a/plugins/machines/__init__.py b/plugins/machines/__init__.py new file mode 100644 index 00000000..a5c1cca1 --- /dev/null +++ b/plugins/machines/__init__.py @@ -0,0 +1,5 @@ +"""Machines plugin for ShopDB.""" + +from .plugin import MachinesPlugin + +__all__ = ['MachinesPlugin'] diff --git a/plugins/machines/api/__init__.py b/plugins/machines/api/__init__.py new file mode 100644 index 00000000..3dd6bc7e --- /dev/null +++ b/plugins/machines/api/__init__.py @@ -0,0 +1,5 @@ +"""Machines plugin API.""" + +from .routes import machines_bp + +__all__ = ['machines_bp'] diff --git a/plugins/machines/api/routes.py b/plugins/machines/api/routes.py new file mode 100644 index 00000000..f309e32f --- /dev/null +++ b/plugins/machines/api/routes.py @@ -0,0 +1,550 @@ +"""Machines plugin API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import db, Asset, AssetType, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, resolve_dualpath_pairs, dualpath_single_machine_enabled + +from ..models import Machine, MachineType + +from shopdb.api import require_permission, apply_import_timestamps + +machines_bp = Blueprint('machines', __name__) + + +# ============================================================================= +# Machine Types +# ============================================================================= + +@machines_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_machine_types(): + """List all machine types.""" + page, per_page = get_pagination_params(request) + + query = MachineType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(MachineType.isactive == True) + + if search := request.args.get('search'): + query = query.filter(MachineType.machinetype.ilike(f'%{search}%')) + + query = query.order_by(MachineType.machinetype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@machines_bp.route('/types/', methods=['GET']) +@jwt_required(optional=True) +def get_machine_type(type_id: int): + """Get a single machine type.""" + t = db.session.get(MachineType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@machines_bp.route('/types', methods=['POST']) +@jwt_required() +@require_permission('machines.create') +def create_machine_type(): + """Create a new machine type.""" + data = request.get_json() + + if not data or not data.get('machinetype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'machinetype is required') + + existing = MachineType.query.filter_by(machinetype=data['machinetype']).first() + if existing: + if not existing.isactive: + existing.isactive = True + for key in ('description', 'icon', 'color'): + if data.get(key) is not None: + setattr(existing, key, data[key]) + db.session.commit() + return success_response(existing.to_dict(), message='Reactivated existing type') + return error_response( + ErrorCodes.CONFLICT, + f"Machine type '{data['machinetype']}' already exists", + http_code=409 + ) + + t = MachineType( + machinetype=data['machinetype'], + description=data.get('description'), + icon=data.get('icon'), color=data.get('color') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Machine type created', http_code=201) + + +@machines_bp.route('/types/', methods=['PUT']) +@jwt_required() +@require_permission('machines.edit') +def update_machine_type(type_id: int): + """Update a machine type.""" + t = db.session.get(MachineType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine type with ID {type_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'machinetype' in data and data['machinetype'] != t.machinetype: + if MachineType.query.filter_by(machinetype=data['machinetype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Machine type '{data['machinetype']}' already exists", + http_code=409 + ) + + for key in ['machinetype', 'description', 'icon', 'color', 'isactive']: + if key in data: + setattr(t, key, data[key]) + + db.session.commit() + return success_response(t.to_dict(), message='Machine type updated') + + +@machines_bp.route('/types/', methods=['DELETE']) +@jwt_required() +@require_permission('machines.delete') +def delete_machine_type(type_id: int): + """Delete a machine type. Refused if any asset still uses it.""" + t = db.session.get(MachineType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Machine type not found', http_code=404) + inuse = Machine.query.filter_by(machinetypeid=type_id).count() + if inuse: + return error_response(ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} asset(s) still use this type", http_code=409) + db.session.delete(t) + db.session.commit() + return success_response(message='Machine type deleted') + + +# ============================================================================= +# Machine CRUD +# ============================================================================= + +@machines_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_machines(): + """ + List all machines with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status + - search: Search by asset number or name + - type_id: Filter by machine type ID + - vendor_id: Filter by vendor ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + """ + page, per_page = get_pagination_params(request) + + # Join Machine with Asset + query = db.session.query(Machine).join(Asset) + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Exact-match natural-key lookup for idempotent import (asset number). + if exactassetnumber := request.args.get('assetnumber'): + query = query.filter(Asset.assetnumber == exactassetnumber) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%') + ) + ) + + # Machine type filter + if type_id := request.args.get('typeid', request.args.get('type_id')): + query = query.filter(Machine.machinetypeid == int(type_id)) + + # Vendor filter + if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')): + query = query.filter(Machine.vendorid == int(vendor_id)) + + # Location filter + if location_id := request.args.get('locationid', request.args.get('location_id')): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Sorting + sort_by = request.args.get('sort', 'assetnumber') + sort_dir = request.args.get('dir', 'asc') + + if sort_by == 'assetnumber': + col = Asset.assetnumber + elif sort_by == 'name': + col = Asset.name + else: + col = Asset.assetnumber + + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + + # Dualpath single-machine collapse: hide the secondary bay so a dual-bay + # pair lists (and paginates) as one machine. Excluding before pagination + # keeps totals honest. Gated on the site setting (default on). + collapse = None + if dualpath_single_machine_enabled(): + collapse = resolve_dualpath_pairs() + if collapse.secondaryassetids: + query = query.filter(Asset.assetid.notin_(collapse.secondaryassetids)) + + items, total = paginate_query(query, page, per_page) + + # Resolve partner machineids for this page's primaries in one query. + machineidbyasset = {} + if collapse: + partnerassetids = [ + collapse.partnerbyasset[m.assetid]['assetid'] + for m in items if m.assetid in collapse.partnerbyasset + ] + if partnerassetids: + for partner in Machine.query.filter( + Machine.assetid.in_(partnerassetids)).all(): + machineidbyasset[partner.assetid] = partner.machineid + + # Build response with both asset and machine data + data = [] + for mach in items: + item = mach.asset.to_dict() if mach.asset else {} + item['machine'] = mach.to_dict() + # annotate the visible bay with its hidden partner (for '2007 / 2008') + partner = collapse.partnerbyasset.get(mach.assetid) if collapse else None + item['dualpathpartner'] = { + 'assetid': partner['assetid'], + 'machineid': machineidbyasset.get(partner['assetid']), + 'assetnumber': partner['assetnumber'], + } if partner else None + data.append(item) + + return paginated_response(data, page, per_page, total) + + +def _dualpath_partner_for(assetid): + """Resolve this machine's Dualpath sibling, or None. Always evaluated + (independent of the collapse toggle) so the detail-page banner shows even + when a site lists both bays. Returns {assetid, machineid, assetnumber}.""" + partner = resolve_dualpath_pairs().partnerbyasset.get(assetid) + if not partner: + return None + partnermach = Machine.query.filter_by(assetid=partner['assetid']).first() + return { + 'assetid': partner['assetid'], + 'machineid': partnermach.machineid if partnermach else None, + 'assetnumber': partner['assetnumber'], + } + + +@machines_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_machine(machine_id: int): + """Get a single machine item with full details.""" + mach = db.session.get(Machine, machine_id) + + if not mach: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine with ID {machine_id} not found', + http_code=404 + ) + + result = mach.asset.to_dict() if mach.asset else {} + result['machine'] = mach.to_dict() + result['dualpathpartner'] = _dualpath_partner_for(mach.assetid) + + return success_response(result) + + +@machines_bp.route('/by-asset/', methods=['GET']) +@jwt_required(optional=True) +def get_machine_by_asset(asset_id: int): + """Get machine data by asset ID.""" + mach = Machine.query.filter_by(assetid=asset_id).first() + + if not mach: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine for asset {asset_id} not found', + http_code=404 + ) + + result = mach.asset.to_dict() if mach.asset else {} + result['machine'] = mach.to_dict() + result['dualpathpartner'] = _dualpath_partner_for(mach.assetid) + + return success_response(result) + + +@machines_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('machines.create') +def create_machine(): + """ + Create new machine (creates both Asset and Machine records). + + Required fields: + - assetnumber: Business identifier + + Optional fields: + - name, serialnumber, statusid, locationid, businessunitid + - machinetypeid, vendorid, modelnumberid + - requiresmanualconfig, islocationonly + - mapx, mapy, notes + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Get machine asset type + machine_type = AssetType.query.filter_by(assettype='machine').first() + if not machine_type: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Machine asset type not found. Plugin may not be properly installed.', + http_code=500 + ) + + # Create the core asset + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), + serialnumber=data.get('serialnumber'), + assettypeid=machine_type.assettypeid, + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapx=data.get('mapx'), + mapy=data.get('mapy'), + notes=data.get('notes') + ) + + db.session.add(asset) + db.session.flush() # Get the assetid + + # Create the machine extension + mach = Machine( + assetid=asset.assetid, + machinetypeid=data.get('machinetypeid'), + vendorid=data.get('vendorid'), + modelnumberid=data.get('modelnumberid'), + requiresmanualconfig=data.get('requiresmanualconfig', False), + islocationonly=data.get('islocationonly', False), + lastmaintenancedate=data.get('lastmaintenancedate'), + nextmaintenancedate=data.get('nextmaintenancedate'), + maintenanceintervaldays=data.get('maintenanceintervaldays'), + controllervendorid=data.get('controllervendorid'), + controllermodelid=data.get('controllermodelid') + ) + + db.session.add(mach) + db.session.flush() + + # Preserve legacy timestamps in import mode (no-op otherwise) + apply_import_timestamps(asset, data) + + # Audit log + AuditLog.log('created', 'Machine', entityid=mach.machineid, + entityname=data['assetnumber']) + + db.session.commit() + + result = asset.to_dict() + result['machine'] = mach.to_dict() + + return success_response(result, message='Machine created', http_code=201) + + +@machines_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('machines.edit') +def update_machine(machine_id: int): + """Update machine (both Asset and Machine records).""" + mach = db.session.get(Machine, machine_id) + + if not mach: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine with ID {machine_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + asset = mach.asset + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Track changes for audit log + changes = {} + + # Update asset fields + asset_fields = ['assetnumber', 'name', 'gaugelabreference', + 'maintenancereference', 'serialnumber', 'statusid', + 'locationid', 'businessunitid', 'mapx', 'mapy', + 'notes', 'isactive'] + for key in asset_fields: + if key in data: + old_val = getattr(asset, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(asset, key, data[key]) + + # Update machine fields + machine_fields = ['machinetypeid', 'vendorid', 'modelnumberid', + 'requiresmanualconfig', 'islocationonly', + 'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays', + 'controllervendorid', 'controllermodelid'] + for key in machine_fields: + if key in data: + old_val = getattr(mach, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(mach, key, data[key]) + + # Audit log if there were changes + if changes: + AuditLog.log('updated', 'Machine', entityid=mach.machineid, + entityname=asset.assetnumber, changes=changes) + + apply_import_timestamps(asset, data) + db.session.commit() + + result = asset.to_dict() + result['machine'] = mach.to_dict() + + return success_response(result, message='Machine updated') + + +@machines_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('machines.delete') +def delete_machine(machine_id: int): + """Delete (soft delete) machine.""" + mach = db.session.get(Machine, machine_id) + + if not mach: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine with ID {machine_id} not found', + http_code=404 + ) + + # Soft delete the asset (machine extension will stay linked) + mach.asset.isactive = False + + # Audit log + AuditLog.log('deleted', 'Machine', entityid=mach.machineid, + entityname=mach.asset.assetnumber) + + db.session.commit() + + return success_response(message='Machine deleted') + + +# ============================================================================= +# Dashboard +# ============================================================================= + +@machines_bp.route('/dashboard/summary', methods=['GET']) +@jwt_required(optional=True) +def dashboard_summary(): + """Get machine dashboard summary data.""" + # Dualpath single-machine collapse: hide the secondary bay from counts so a + # dual-bay pair counts once. Gated on the site setting (default on). + secondaryassetids = set() + if dualpath_single_machine_enabled(): + secondaryassetids = resolve_dualpath_pairs().secondaryassetids + + # Total active machine count + total_query = db.session.query(Machine).join(Asset).filter( + Asset.isactive == True + ) + if secondaryassetids: + total_query = total_query.filter(Asset.assetid.notin_(secondaryassetids)) + total = total_query.count() + + # Count by machine type + by_type_query = db.session.query( + MachineType.machinetype, + db.func.count(Machine.machineid) + ).join(Machine, Machine.machinetypeid == MachineType.machinetypeid + ).join(Asset, Asset.assetid == Machine.assetid + ).filter(Asset.isactive == True) + if secondaryassetids: + by_type_query = by_type_query.filter(Asset.assetid.notin_(secondaryassetids)) + by_type = by_type_query.group_by(MachineType.machinetype).all() + + # Count by status + from shopdb.api import AssetStatus + by_status = db.session.query( + AssetStatus.status, + db.func.count(Machine.machineid) + ).join(Asset, Asset.assetid == Machine.assetid + ).join(AssetStatus, AssetStatus.statusid == Asset.statusid + ).filter(Asset.isactive == True + ).group_by(AssetStatus.status + ).all() + + return success_response({ + 'total': total, + 'bytype': [{'type': t, 'count': c} for t, c in by_type], + 'bystatus': [{'status': s, 'count': c} for s, c in by_status] + }) diff --git a/plugins/machines/manifest.json b/plugins/machines/manifest.json new file mode 100644 index 00000000..58542b34 --- /dev/null +++ b/plugins/machines/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "machines", + "version": "1.0.0", + "description": "Machine management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing machines", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/machines", + "provides": { + "asset_type": "machine", + "features": [ + "machine_tracking", + "maintenance_scheduling", + "vendor_management", + "model_catalog" + ] + }, + "settings": { + "enable_maintenance_alerts": true, + "maintenance_alert_days": 30 + } +} diff --git a/plugins/machines/migrations/env.py b/plugins/machines/migrations/env.py new file mode 100644 index 00000000..37e15a7c --- /dev/null +++ b/plugins/machines/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the machines 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_machines. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'machines' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/machines/migrations/script.py.mako b/plugins/machines/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/machines/migrations/script.py.mako @@ -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"} diff --git a/plugins/machines/migrations/versions/0001_machines_anchor.py b/plugins/machines/migrations/versions/0001_machines_anchor.py new file mode 100644 index 00000000..977560e6 --- /dev/null +++ b/plugins/machines/migrations/versions/0001_machines_anchor.py @@ -0,0 +1,32 @@ +"""machines plugin anchor (ownership cutover; authored as equipment). + +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_machines. From this +anchor forward, new machines schema changes land as 000N revisions in this +directory, never in the core chain. See ADR-008. + +The revision id keeps its original 'equipment0001anchor' string: ids are +arbitrary and existing installs already carry it in their version table +(which the core chain renames to alembic_version_machines). +""" +from alembic import op # noqa: F401 +import sqlalchemy as sa # noqa: F401 + + +# revision identifiers, used by Alembic. +revision = 'equipment0001anchor' +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 diff --git a/plugins/machines/migrations/versions/0002_rename_from_equipment.py b/plugins/machines/migrations/versions/0002_rename_from_equipment.py new file mode 100644 index 00000000..3ae3066e --- /dev/null +++ b/plugins/machines/migrations/versions/0002_rename_from_equipment.py @@ -0,0 +1,168 @@ +"""Rename equipment tables to machines + +Plugin half of the approved equipment -> machines rename (the core half is +migrations/versions/7d17_machines_rename.py): + + equipment -> machines (equipmentid -> machineid, + equipmenttypeid -> machinetypeid) + equipmenttypes -> machinetypes (equipmenttypeid -> machinetypeid, + equipmenttype -> machinetype) + +Runs on BOTH upgraded installs and fresh installs: the core baseline creates +these tables under their old names (frozen DDL), so this revision always does +the physical rename. Idempotent: skips when the tables already carry the new +names (e.g. test DBs built by db.create_all() from current models). Refuses +to run while the core machinetypes table still exists - `flask db upgrade` +(which renames it to modeltypes) must land first. + +Revision ID: machines0002rename +Revises: equipment0001anchor +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'machines0002rename' +down_revision = 'equipment0001anchor' +branch_labels = None +depends_on = None + + +def _fk_names(insp, table, referred_table): + # find FK constraint names on table pointing at referred_table + return [fk['name'] for fk in insp.get_foreign_keys(table) + if fk.get('referred_table') == referred_table and fk.get('name')] + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + tables = set(insp.get_table_names()) + + if 'equipment' not in tables and 'equipmenttypes' not in tables: + # already renamed, or fresh schema built straight from current models + return + + if 'machinetypes' in tables: + # core chain still owns the machinetypes name (not yet renamed to + # modeltypes) - renaming equipmenttypes onto it would collide + raise RuntimeError( + "Core migration 7d17_machines_rename has not run: the legacy " + "machinetypes table still exists. Run `flask db upgrade` before " + "`flask plugin upgrade-all`." + ) + + if bind.dialect.name == 'mysql': + _upgrade_mysql(bind, insp) + else: + _upgrade_generic(insp) + + +def _upgrade_mysql(bind, insp): + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0") + + # snapshot index names before the renames invalidate the table name + machine_indexes = {ix['name'] for ix in insp.get_indexes('equipment')} + + # drop the equipment -> equipmenttypes FK before renaming the parent cols + for name in _fk_names(insp, 'equipment', 'equipmenttypes'): + bind.exec_driver_sql(f"ALTER TABLE equipment DROP FOREIGN KEY {name}") + + bind.exec_driver_sql("RENAME TABLE equipmenttypes TO machinetypes") + bind.exec_driver_sql("RENAME TABLE equipment TO machines") + + bind.exec_driver_sql( + "ALTER TABLE machinetypes " + "CHANGE equipmenttypeid machinetypeid INT NOT NULL AUTO_INCREMENT, " + "CHANGE equipmenttype machinetype VARCHAR(100) NOT NULL, " + "DROP KEY equipmenttype, " + "ADD UNIQUE KEY machinetype (machinetype)" + ) + + parts = [ + "CHANGE equipmentid machineid INT NOT NULL AUTO_INCREMENT", + "CHANGE equipmenttypeid machinetypeid INT NULL", + ] + # rename the model-declared index names to match the Machine model + if 'idx_equipment_type' in machine_indexes: + parts += ["DROP KEY idx_equipment_type", + "ADD KEY idx_machine_type (machinetypeid)"] + if 'idx_equipment_vendor' in machine_indexes: + parts += ["DROP KEY idx_equipment_vendor", + "ADD KEY idx_machine_vendor (vendorid)"] + if 'ix_equipment_assetid' in machine_indexes: + parts += ["DROP KEY ix_equipment_assetid", + "ADD UNIQUE KEY ix_machines_assetid (assetid)"] + parts += [ + "ADD CONSTRAINT fk_machines_machinetypeid " + "FOREIGN KEY (machinetypeid) REFERENCES machinetypes (machinetypeid)" + ] + bind.exec_driver_sql("ALTER TABLE machines " + ", ".join(parts)) + + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1") + + +def _upgrade_generic(insp): + # SQLite (and anything else): batch rename via ALTER ... RENAME + op.rename_table('equipmenttypes', 'machinetypes') + op.rename_table('equipment', 'machines') + with op.batch_alter_table('machinetypes') as batch_op: + batch_op.alter_column('equipmenttypeid', new_column_name='machinetypeid', + existing_type=sa.Integer(), existing_nullable=False) + batch_op.alter_column('equipmenttype', new_column_name='machinetype', + existing_type=sa.String(100), + existing_nullable=False) + with op.batch_alter_table('machines') as batch_op: + batch_op.alter_column('equipmentid', new_column_name='machineid', + existing_type=sa.Integer(), existing_nullable=False) + batch_op.alter_column('equipmenttypeid', new_column_name='machinetypeid', + existing_type=sa.Integer(), existing_nullable=True) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + tables = set(insp.get_table_names()) + + if 'machines' not in tables and 'machinetypes' not in tables: + return + + if bind.dialect.name != 'mysql': + raise NotImplementedError( + "downgrade implemented for MySQL only (dev/prod dialect)") + + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0") + machine_indexes = {ix['name'] for ix in insp.get_indexes('machines')} + for name in _fk_names(insp, 'machines', 'machinetypes'): + bind.exec_driver_sql(f"ALTER TABLE machines DROP FOREIGN KEY {name}") + + bind.exec_driver_sql("RENAME TABLE machinetypes TO equipmenttypes") + bind.exec_driver_sql("RENAME TABLE machines TO equipment") + + bind.exec_driver_sql( + "ALTER TABLE equipmenttypes " + "CHANGE machinetypeid equipmenttypeid INT NOT NULL AUTO_INCREMENT, " + "CHANGE machinetype equipmenttype VARCHAR(100) NOT NULL, " + "DROP KEY machinetype, " + "ADD UNIQUE KEY equipmenttype (equipmenttype)" + ) + + parts = [ + "CHANGE machineid equipmentid INT NOT NULL AUTO_INCREMENT", + "CHANGE machinetypeid equipmenttypeid INT NULL", + ] + if 'idx_machine_type' in machine_indexes: + parts += ["DROP KEY idx_machine_type", + "ADD KEY idx_equipment_type (equipmenttypeid)"] + if 'idx_machine_vendor' in machine_indexes: + parts += ["DROP KEY idx_machine_vendor", + "ADD KEY idx_equipment_vendor (vendorid)"] + if 'ix_machines_assetid' in machine_indexes: + parts += ["DROP KEY ix_machines_assetid", + "ADD UNIQUE KEY ix_equipment_assetid (assetid)"] + parts += [ + "ADD CONSTRAINT equipment_ibfk_2 " + "FOREIGN KEY (equipmenttypeid) REFERENCES equipmenttypes (equipmenttypeid)" + ] + bind.exec_driver_sql("ALTER TABLE equipment " + ", ".join(parts)) + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1") diff --git a/plugins/machines/models/__init__.py b/plugins/machines/models/__init__.py new file mode 100644 index 00000000..5712ecad --- /dev/null +++ b/plugins/machines/models/__init__.py @@ -0,0 +1,8 @@ +"""Machines plugin models.""" + +from .machine import Machine, MachineType + +__all__ = [ + 'Machine', + 'MachineType', +] diff --git a/plugins/machines/models/machine.py b/plugins/machines/models/machine.py new file mode 100644 index 00000000..d73090c5 --- /dev/null +++ b/plugins/machines/models/machine.py @@ -0,0 +1,133 @@ +"""Machines plugin models.""" + +from shopdb.api import db, BaseModel + + +class MachineType(BaseModel): + """ + Machine type classification. + + Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc. + """ + __tablename__ = 'machinetypes' + + machinetypeid = db.Column(db.Integer, primary_key=True) + machinetype = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + color = db.Column(db.String(20), comment='CSS color for UI/map markers') + + def __repr__(self): + return f"" + + +class Machine(BaseModel): + """ + Machine-specific extension data. + + Links to core Asset table via assetid. + Stores machine-specific fields like type, model, vendor, etc. + """ + __tablename__ = 'machines' + + machineid = db.Column(db.Integer, primary_key=True) + + # Link to core asset + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Machine classification + machinetypeid = db.Column( + db.Integer, + db.ForeignKey('machinetypes.machinetypeid'), + nullable=True + ) + + # Vendor and model + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + modelnumberid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=True + ) + + # Machine-specific fields + requiresmanualconfig = db.Column( + db.Boolean, + default=False, + comment='Multi-PC machine needs manual configuration' + ) + islocationonly = db.Column( + db.Boolean, + default=False, + comment='Virtual location marker (not actual machine)' + ) + + # Maintenance tracking + lastmaintenancedate = db.Column(db.DateTime, nullable=True) + nextmaintenancedate = db.Column(db.DateTime, nullable=True) + maintenanceintervaldays = db.Column(db.Integer, nullable=True) + + # Controller info (for CNC machines) + controllervendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True, + comment='Controller vendor (e.g., FANUC)' + ) + controllermodelid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=True, + comment='Controller model (e.g., 31B)' + ) + + # Relationships + asset = db.relationship( + 'Asset', + backref=db.backref('machine', uselist=False, lazy='joined') + ) + machinetype = db.relationship('MachineType', backref='machines') + vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='machine_items') + model = db.relationship('Model', foreign_keys=[modelnumberid], backref='machine_items') + controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='machine_controllers') + controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='machine_controller_models') + + __table_args__ = ( + db.Index('idx_machine_type', 'machinetypeid'), + db.Index('idx_machine_vendor', 'vendorid'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.machinetype: + result['machinetypename'] = self.machinetype.machinetype + if self.vendor: + result['vendorname'] = self.vendor.vendor + if self.model: + result['modelname'] = self.model.modelnumber + if self.model.imageurl: + result['imageurl'] = self.model.imageurl + + # Add controller info + if self.controllervendor: + result['controllervendorname'] = self.controllervendor.vendor + if self.controllermodel: + result['controllermodelname'] = self.controllermodel.modelnumber + + return result diff --git a/plugins/machines/plugin.py b/plugins/machines/plugin.py new file mode 100644 index 00000000..177d15e4 --- /dev/null +++ b/plugins/machines/plugin.py @@ -0,0 +1,228 @@ +"""Machines plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint +import click + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db, AssetType, AssetStatus + +from .models import Machine, MachineType +from .api import machines_bp + +logger = logging.getLogger(__name__) + + +class MachinesPlugin(BasePlugin): + """ + Machines plugin - manages manufacturing machine assets. + + Machines include CNCs, CMMs, lathes, grinders, EDMs, part markers, etc. + Uses the new Asset architecture with Machine extension table. + """ + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + manifestpath = Path(__file__).parent / 'manifest.json' + if manifestpath.exists(): + with open(manifestpath, 'r') as f: + return json.load(f) + return {} + + @property + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'machines'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Machine management for manufacturing assets' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/machines'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return machines_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [Machine, MachineType] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Machines plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensure_asset_type() + self._ensure_asset_statuses() + self._ensure_machine_types() + logger.info("Machines plugin installed") + + def _ensure_asset_type(self) -> None: + """Ensure machine asset type exists.""" + existing = AssetType.query.filter_by(assettype='machine').first() + if not existing: + at = AssetType( + assettype='machine', + pluginname='machines', + tablename='machines', + description='Manufacturing machines (CNCs, CMMs, lathes, etc.)', + icon='cog' + ) + db.session.add(at) + logger.debug("Created asset type: machine") + db.session.commit() + + def _ensure_asset_statuses(self) -> None: + """Ensure standard asset statuses exist.""" + statuses = [ + ('In Use', 'Asset is currently in use', '#28a745'), + ('Spare', 'Spare/backup asset', '#17a2b8'), + ('Retired', 'Asset has been retired', '#6c757d'), + ('Maintenance', 'Asset is under maintenance', '#ffc107'), + ('Decommissioned', 'Asset has been decommissioned', '#dc3545'), + ] + + for name, description, color in statuses: + existing = AssetStatus.query.filter_by(status=name).first() + if not existing: + s = AssetStatus( + status=name, + description=description, + color=color + ) + db.session.add(s) + logger.debug(f"Created asset status: {name}") + + db.session.commit() + + def _ensure_machine_types(self) -> None: + """Ensure basic machine types exist.""" + machine_types = [ + ('CNC', 'Computer Numerical Control machine', 'cnc'), + ('CMM', 'Coordinate Measuring Machine', 'cmm'), + ('Lathe', 'Lathe machine', 'lathe'), + ('Grinder', 'Grinding machine', 'grinder'), + ('EDM', 'Electrical Discharge Machine', 'edm'), + ('Part Marker', 'Part marking/engraving machine', 'marker'), + ('Mill', 'Milling machine', 'mill'), + ('Press', 'Press machine', 'press'), + ('Robot', 'Industrial robot', 'robot'), + ('Other', 'Other machine type', 'cog'), + ] + + for name, description, icon in machine_types: + existing = MachineType.query.filter_by(machinetype=name).first() + if not existing: + mt = MachineType( + machinetype=name, + description=description, + icon=icon + ) + db.session.add(mt) + logger.debug(f"Created machine type: {name}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Machines plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('machines') + def machinescli(): + """Machines plugin commands.""" + pass + + @machinescli.command('list-types') + def list_types(): + """List all machine types.""" + from flask import current_app + + with current_app.app_context(): + types = MachineType.query.filter_by(isactive=True).all() + if not types: + click.echo('No machine types found.') + return + + click.echo('Machine Types:') + for t in types: + click.echo(f" [{t.machinetypeid}] {t.machinetype}") + + @machinescli.command('stats') + def stats(): + """Show machine statistics.""" + from flask import current_app + from shopdb.api import Asset + + with current_app.app_context(): + total = db.session.query(Machine).join(Asset).filter( + Asset.isactive == True + ).count() + + click.echo(f"Total active machines: {total}") + + # By type + by_type = db.session.query( + MachineType.machinetype, + db.func.count(Machine.machineid) + ).join(Machine, Machine.machinetypeid == MachineType.machinetypeid + ).join(Asset, Asset.assetid == Machine.assetid + ).filter(Asset.isactive == True + ).group_by(MachineType.machinetype + ).all() + + if by_type: + click.echo("\nBy Type:") + for t, c in by_type: + click.echo(f" {t}: {c}") + + return [machinescli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Machine Status', + 'component': 'MachineStatusWidget', + 'endpoint': '/api/machines/dashboard/summary', + 'size': 'medium', + 'position': 5, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Machines', + 'icon': 'cog', + 'route': '/machines', + 'position': 10, + }, + ] + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('machines.view', 'View machines', 'machines'), + ('machines.create', 'Create machines', 'machines'), + ('machines.edit', 'Edit machines', 'machines'), + ('machines.delete', 'Delete machines', 'machines'), + ] diff --git a/plugins/measuringtools/__init__.py b/plugins/measuringtools/__init__.py new file mode 100644 index 00000000..a4d9da23 --- /dev/null +++ b/plugins/measuringtools/__init__.py @@ -0,0 +1,5 @@ +"""Measuring-tools plugin package.""" + +from .plugin import MeasuringToolsPlugin + +__all__ = ['MeasuringToolsPlugin'] diff --git a/plugins/measuringtools/api/__init__.py b/plugins/measuringtools/api/__init__.py new file mode 100644 index 00000000..58ffce94 --- /dev/null +++ b/plugins/measuringtools/api/__init__.py @@ -0,0 +1,5 @@ +"""Measuring-tools plugin API blueprint.""" + +from .routes import measuringtools_bp + +__all__ = ['measuringtools_bp'] diff --git a/plugins/measuringtools/api/routes.py b/plugins/measuringtools/api/routes.py new file mode 100644 index 00000000..36ad8214 --- /dev/null +++ b/plugins/measuringtools/api/routes.py @@ -0,0 +1,395 @@ +"""Measuring-tools plugin API. + +Two resources: the measuringtooltypes lookup (CRUD with an in-use delete guard) +and the measuring tools themselves (an Asset core row plus a measuringtools +extension row, written in one payload). Reads are jwt-optional per the app +convention; writes require the measuringtools.* permissions. + +Calibration status is derived at read time in the model (see +models.derive_status), never stored, so the report and list badges are always +current. +""" + +from datetime import date, datetime + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import ( + db, Asset, AssetType, AuditLog, + success_response, error_response, paginated_response, ErrorCodes, + get_pagination_params, paginate_query, + require_permission, apply_import_timestamps, +) + +from ..models import MeasuringTool, MeasuringToolType, derive_status, STATUS_COLORS + +measuringtools_bp = Blueprint('measuringtools', __name__) + + +def _parse_date(value): + """Accept 'YYYY-MM-DD' (or None/empty) -> date or None.""" + if not value: + return None + try: + return datetime.strptime(str(value)[:10], '%Y-%m-%d').date() + except (ValueError, TypeError): + return None + + +def _merged(tool, today=None): + """Asset core dict with the measuringtools extension nested under 'measuringtool'.""" + result = tool.asset.to_dict() if tool.asset else {} + result['measuringtool'] = tool.to_dict(today) + return result + + +# ============================================================================= +# Measuring-tool types +# ============================================================================= + +@measuringtools_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_types(): + """List measuring-tool types.""" + page, per_page = get_pagination_params(request) + query = MeasuringToolType.query + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(MeasuringToolType.isactive == True) + if search := request.args.get('search'): + query = query.filter(MeasuringToolType.name.ilike(f'%{search}%')) + query = query.order_by(MeasuringToolType.name) + items, total = paginate_query(query, page, per_page) + return paginated_response([t.to_dict() for t in items], page, per_page, total) + + +@measuringtools_bp.route('/types/', methods=['GET']) +@jwt_required(optional=True) +def get_type(type_id: int): + """Get one measuring-tool type.""" + tool_type = db.session.get(MeasuringToolType, type_id) + if not tool_type: + return error_response(ErrorCodes.NOT_FOUND, + f'Measuring-tool type {type_id} not found', http_code=404) + return success_response(tool_type.to_dict()) + + +@measuringtools_bp.route('/types', methods=['POST']) +@jwt_required() +@require_permission('measuringtools.create') +def create_type(): + """Create a measuring-tool type (reactivates a soft-deleted same-named one).""" + data = request.get_json() or {} + name = (data.get('name') or '').strip() + if not name: + return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') + + existing = MeasuringToolType.query.filter_by(name=name).first() + if existing: + if not existing.isactive: + existing.isactive = True + for key in ('description', 'color'): + if data.get(key) is not None: + setattr(existing, key, data[key]) + db.session.commit() + return success_response(existing.to_dict(), message='Reactivated existing type') + return error_response(ErrorCodes.CONFLICT, + f"Measuring-tool type '{name}' already exists", http_code=409) + + tool_type = MeasuringToolType( + name=name, + description=data.get('description'), + color=data.get('color'), + ) + db.session.add(tool_type) + db.session.commit() + return success_response(tool_type.to_dict(), message='Measuring-tool type created', + http_code=201) + + +@measuringtools_bp.route('/types/', methods=['PUT']) +@jwt_required() +@require_permission('measuringtools.edit') +def update_type(type_id: int): + """Update a measuring-tool type.""" + tool_type = db.session.get(MeasuringToolType, type_id) + if not tool_type: + return error_response(ErrorCodes.NOT_FOUND, + f'Measuring-tool type {type_id} not found', http_code=404) + data = request.get_json() or {} + if 'name' in data and data['name'] != tool_type.name: + if MeasuringToolType.query.filter_by(name=data['name']).first(): + return error_response(ErrorCodes.CONFLICT, + f"Measuring-tool type '{data['name']}' already exists", + http_code=409) + for key in ('name', 'description', 'color', 'isactive'): + if key in data: + setattr(tool_type, key, data[key]) + db.session.commit() + return success_response(tool_type.to_dict(), message='Measuring-tool type updated') + + +@measuringtools_bp.route('/types/', methods=['DELETE']) +@jwt_required() +@require_permission('measuringtools.delete') +def delete_type(type_id: int): + """Delete a measuring-tool type. Refused if any tool still uses it.""" + tool_type = db.session.get(MeasuringToolType, type_id) + if not tool_type: + return error_response(ErrorCodes.NOT_FOUND, + f'Measuring-tool type {type_id} not found', http_code=404) + inuse = MeasuringTool.query.filter_by(measuringtooltypeid=type_id).count() + if inuse: + return error_response(ErrorCodes.CONFLICT, + f'Cannot delete: {inuse} tool(s) still use this type', + http_code=409) + db.session.delete(tool_type) + db.session.commit() + return success_response(message='Measuring-tool type deleted') + + +# ============================================================================= +# Measuring tools +# ============================================================================= + +@measuringtools_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_tools(): + """List measuring tools with filters + pagination. + + Query params: typeid, locationid, statusid, calibrationstatus, search, + active, page, perpage. + """ + page, per_page = get_pagination_params(request) + query = db.session.query(MeasuringTool).join(Asset) + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + # Exact-match natural-key lookup for idempotent import (asset number). + if exactassetnumber := request.args.get('assetnumber'): + query = query.filter(Asset.assetnumber == exactassetnumber) + if search := request.args.get('search'): + query = query.filter(db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%'), + )) + if type_id := request.args.get('typeid', type=int): + query = query.filter(MeasuringTool.measuringtooltypeid == type_id) + if location_id := request.args.get('locationid', type=int): + query = query.filter(Asset.locationid == location_id) + if status_id := request.args.get('statusid', type=int): + query = query.filter(Asset.statusid == status_id) + + query = query.order_by(Asset.assetnumber) + items, total = paginate_query(query, page, per_page) + + today = date.today() + data = [_merged(tool, today) for tool in items] + + # Calibration status is derived, so it cannot be a SQL filter - apply it + # to the built rows. Filtering post-pagination is acceptable here because + # the dataset is a gage lab's worth of tools, not a fleet. + calibrationstatus = request.args.get('calibrationstatus') + if calibrationstatus: + data = [d for d in data + if d['measuringtool']['calibrationstatus'] == calibrationstatus] + + return paginated_response(data, page, per_page, total) + + +@measuringtools_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_tool(tool_id: int): + """Get one measuring tool (asset core merged with the extension).""" + tool = db.session.get(MeasuringTool, tool_id) + if not tool: + return error_response(ErrorCodes.NOT_FOUND, + f'Measuring tool {tool_id} not found', http_code=404) + return success_response(_merged(tool)) + + +@measuringtools_bp.route('/by-asset/', methods=['GET']) +@jwt_required(optional=True) +def get_tool_by_asset(asset_id: int): + """Get a measuring tool by its core asset id.""" + tool = MeasuringTool.query.filter_by(assetid=asset_id).first() + if not tool: + return error_response(ErrorCodes.NOT_FOUND, + f'Measuring tool for asset {asset_id} not found', http_code=404) + return success_response(_merged(tool)) + + +def _asset_type_id(): + """Resolve the seeded 'measuring_tool' asset-type id, or None.""" + asset_type = AssetType.query.filter_by(assettype='measuring_tool').first() + return asset_type.assettypeid if asset_type else None + + +@measuringtools_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('measuringtools.create') +def create_tool(): + """Create a measuring tool: one Asset core row + one extension row.""" + data = request.get_json() or {} + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response(ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409) + + assettypeid = _asset_type_id() + if not assettypeid: + return error_response(ErrorCodes.INTERNAL_ERROR, + 'measuring_tool asset type not found. Plugin may not be ' + 'properly installed.', http_code=500) + + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), + serialnumber=data.get('serialnumber'), + assettypeid=assettypeid, + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapx=data.get('mapx'), + mapy=data.get('mapy'), + notes=data.get('notes'), + ) + db.session.add(asset) + db.session.flush() # assign assetid + + tool = MeasuringTool( + assetid=asset.assetid, + measuringtooltypeid=data.get('measuringtooltypeid'), + calibrationintervaldays=data.get('calibrationintervaldays'), + lastcalibrationdate=_parse_date(data.get('lastcalibrationdate')), + nextcalibrationdate=_parse_date(data.get('nextcalibrationdate')), + calibrationprovider=(data.get('calibrationprovider') or '').strip() or None, + notes=(data.get('notes') or '').strip() or None, + ) + db.session.add(tool) + db.session.flush() + + apply_import_timestamps(asset, data) + AuditLog.log('created', 'MeasuringTool', entityid=tool.measuringtoolid, + entityname=asset.assetnumber) + db.session.commit() + return success_response(_merged(tool), message='Measuring tool created', http_code=201) + + +# Asset core fields writable through this plugin's write path. +_ASSET_FIELDS = ('assetnumber', 'name', 'gaugelabreference', + 'maintenancereference', 'serialnumber', + 'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', + 'notes', 'isactive') +# Extension fields with plain assignment (dates handled separately). +_TOOL_FIELDS = ('measuringtooltypeid', 'calibrationintervaldays', + 'calibrationprovider', 'notes') + + +@measuringtools_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('measuringtools.edit') +def update_tool(tool_id: int): + """Update a measuring tool (asset core fields + extension in one payload).""" + tool = db.session.get(MeasuringTool, tool_id) + if not tool: + return error_response(ErrorCodes.NOT_FOUND, + f'Measuring tool {tool_id} not found', http_code=404) + data = request.get_json() or {} + asset = tool.asset + + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response(ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409) + + changes = {} + for key in _ASSET_FIELDS: + if key in data: + if getattr(asset, key) != data[key]: + changes[key] = {'old': getattr(asset, key), 'new': data[key]} + setattr(asset, key, data[key]) + for key in _TOOL_FIELDS: + if key in data: + if getattr(tool, key) != data[key]: + changes[key] = {'old': getattr(tool, key), 'new': data[key]} + setattr(tool, key, data[key]) + for key in ('lastcalibrationdate', 'nextcalibrationdate'): + if key in data: + setattr(tool, key, _parse_date(data[key])) + + if changes: + AuditLog.log('updated', 'MeasuringTool', entityid=tool.measuringtoolid, + entityname=asset.assetnumber, changes=changes) + apply_import_timestamps(asset, data) + db.session.commit() + return success_response(_merged(tool), message='Measuring tool updated') + + +@measuringtools_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('measuringtools.delete') +def delete_tool(tool_id: int): + """Soft delete a measuring tool (deactivates its asset).""" + tool = db.session.get(MeasuringTool, tool_id) + if not tool: + return error_response(ErrorCodes.NOT_FOUND, + f'Measuring tool {tool_id} not found', http_code=404) + tool.asset.isactive = False + AuditLog.log('deleted', 'MeasuringTool', entityid=tool.measuringtoolid, + entityname=tool.asset.assetnumber) + db.session.commit() + return success_response(message='Measuring tool deleted') + + +# ============================================================================= +# Map overlay (ADR-010 calibration-due badge) +# ============================================================================= + +@measuringtools_bp.route('/map-overlay', methods=['GET']) +@jwt_required(optional=True) +def map_overlay(): + """Calibration-status overlay for active measuring tools. + + The map places markers itself from the assets feed; this overlay only + supplies the per-asset calibration decoration. Consumers join by assetid, + so no map coordinates are returned. Status is derived at read time. + """ + today = date.today() + query = db.session.query(MeasuringTool).join(Asset).filter(Asset.isactive == True) + data = [] + for tool in query.all(): + status = derive_status(tool.nextcalibrationdate, today) + data.append({ + 'assetid': tool.assetid, + 'calibrationstatus': status, + 'statuscolor': STATUS_COLORS.get(status, STATUS_COLORS['unknown']), + }) + return success_response(data) + + +# ============================================================================= +# Calibration report (for the Reports hub) +# ============================================================================= + +@measuringtools_bp.route('/report/calibration', methods=['GET']) +@jwt_required(optional=True) +def calibration_report(): + """Counts + lists bucketed by derived calibration status.""" + today = date.today() + buckets = {'overdue': [], 'duesoon': [], 'current': [], 'unknown': []} + query = db.session.query(MeasuringTool).join(Asset).filter(Asset.isactive == True) + for tool in query.all(): + buckets.setdefault(derive_status(tool.nextcalibrationdate, today), []).append( + _merged(tool, today)) + return success_response({ + 'counts': {key: len(value) for key, value in buckets.items()}, + 'buckets': buckets, + 'statuscolors': STATUS_COLORS, + }) diff --git a/plugins/measuringtools/manifest.json b/plugins/measuringtools/manifest.json new file mode 100644 index 00000000..f61c03e3 --- /dev/null +++ b/plugins/measuringtools/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "measuringtools", + "display_name": "Measuring Tools", + "version": "1.0.0", + "description": "Metrology and inspection instruments (gauges, calipers, thread gages, bore gages) with a calibration lifecycle and derived calibration status.", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.6.0,<1.0.0", + "api_prefix": "/api/measuringtools", + "default_enabled": false, + "provides": { + "features": ["measuring-tools", "calibration-tracking"] + } +} diff --git a/plugins/measuringtools/migrations/env.py b/plugins/measuringtools/migrations/env.py new file mode 100644 index 00000000..709e2ef6 --- /dev/null +++ b/plugins/measuringtools/migrations/env.py @@ -0,0 +1,16 @@ +"""Alembic environment for the measuring-tools 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_measuringtools. See ADR-008 for the ownership +model. Unlike the ten cutover plugins whose 0001 is a no-op anchor, this plugin +is NEW: its 0001 baseline really CREATES its tables, because the core chain +never built them. +""" +import os + +os.environ['PLUGIN_NAME'] = 'measuringtools' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/measuringtools/migrations/script.py.mako b/plugins/measuringtools/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/measuringtools/migrations/script.py.mako @@ -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"} diff --git a/plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py b/plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py new file mode 100644 index 00000000..8b102464 --- /dev/null +++ b/plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py @@ -0,0 +1,69 @@ +"""measuringtools plugin baseline (real create). + +This plugin was built AFTER the ADR-008 ownership cutover, so unlike the ten +bundled plugins whose 0001 is a stamp-only no-op anchor, this baseline actually +CREATES the plugin's tables. The core Alembic chain never knew about +measuringtooltypes / measuringtools, so this per-plugin chain is their sole +authoritative creator. It runs from `flask plugin install measuringtools` +(and `flask plugin upgrade-all`) after `flask db upgrade` builds the core schema. + +Note on create_plugin_tables: the shared helper in +shopdb.plugins.alembic_template builds a per-plugin MetaData filtered to only the +plugin's own tables, which means a foreign key to a core table (assets) cannot +be resolved at CreateTable-compile time (NoReferencedTableError). Since these +tables reference assets.assetid, the baseline emits explicit Alembic ops (the +same shape Alembic autogenerate produces) instead. Tables inherit the +connection's default charset, so on a utf8mb4 database they are utf8mb4, matching +how the core chain creates its tables. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'measuringtools0001baseline' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'measuringtooltypes', + sa.Column('measuringtooltypeid', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=20), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('measuringtooltypeid'), + sa.UniqueConstraint('name'), + ) + op.create_table( + 'measuringtools', + sa.Column('measuringtoolid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('measuringtooltypeid', sa.Integer(), nullable=True), + sa.Column('calibrationintervaldays', sa.Integer(), nullable=True), + sa.Column('lastcalibrationdate', sa.Date(), nullable=True), + sa.Column('nextcalibrationdate', sa.Date(), nullable=True), + sa.Column('calibrationprovider', sa.String(length=150), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['measuringtooltypeid'], + ['measuringtooltypes.measuringtooltypeid']), + sa.PrimaryKeyConstraint('measuringtoolid'), + sa.UniqueConstraint('assetid'), + ) + op.create_index('idx_measuringtool_type', 'measuringtools', + ['measuringtooltypeid']) + + +def downgrade(): + op.drop_index('idx_measuringtool_type', table_name='measuringtools') + op.drop_table('measuringtools') + op.drop_table('measuringtooltypes') diff --git a/plugins/measuringtools/models/__init__.py b/plugins/measuringtools/models/__init__.py new file mode 100644 index 00000000..f1f9f8de --- /dev/null +++ b/plugins/measuringtools/models/__init__.py @@ -0,0 +1,17 @@ +"""Measuring-tools plugin models.""" + +from .measuringtool import ( + MeasuringTool, + MeasuringToolType, + derive_status, + STATUS_COLORS, + DUESOON_WINDOW_DAYS, +) + +__all__ = [ + 'MeasuringTool', + 'MeasuringToolType', + 'derive_status', + 'STATUS_COLORS', + 'DUESOON_WINDOW_DAYS', +] diff --git a/plugins/measuringtools/models/measuringtool.py b/plugins/measuringtools/models/measuringtool.py new file mode 100644 index 00000000..78d9464e --- /dev/null +++ b/plugins/measuringtools/models/measuringtool.py @@ -0,0 +1,133 @@ +"""Measuring-tools models. + +Measuring tools are gage-lab instruments (calipers, micrometers, thread gages, +bore gages, height gages, Genspect heads, ...) that measure parts, as opposed +to machines that make parts (see ADR-005). Each tool is a core Asset plus a +one-to-one measuringtools extension row. + +The lifecycle a measuring tool cares about is CALIBRATION, not maintenance: +an interval, a last date, and a next date. Calibration STATUS is DERIVED from +nextcalibrationdate at read time (see derive_status), never stored, so it is +always current no matter how long since the last write. This mirrors the +warranty plugin's derive-at-read pattern. +""" + +from datetime import date, timedelta + +from shopdb.api import db, BaseModel + +# Window before nextcalibrationdate where a tool counts as "due soon". +DUESOON_WINDOW_DAYS = 30 + +# Derived status -> display color (hex). Reused by the frontend status badge. +STATUS_COLORS = { + 'overdue': '#F44336', + 'duesoon': '#FF9800', + 'current': '#4CAF50', + 'unknown': '#9E9E9E', +} + + +def derive_status(nextcalibrationdate, today=None): + """Calibration status from a next-calibration date. Never stored. + + overdue - the next date is in the past + duesoon - the next date is within DUESOON_WINDOW_DAYS from today + current - the next date is further out than the window + unknown - no next date recorded + """ + if not nextcalibrationdate: + return 'unknown' + today = today or date.today() + if nextcalibrationdate < today: + return 'overdue' + if nextcalibrationdate <= today + timedelta(days=DUESOON_WINDOW_DAYS): + return 'duesoon' + return 'current' + + +class MeasuringToolType(BaseModel): + """Measuring-tool classification (Caliper, Micrometer, Thread Gage, ...). + + Site-managed lookup with a display color for badges and map markers, + the same shape as the machine/computer/printer type tables. + """ + __tablename__ = 'measuringtooltypes' + + measuringtooltypeid = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + color = db.Column(db.String(20), comment='CSS color for UI/map markers') + + def __repr__(self): + return f"" + + +class MeasuringTool(BaseModel): + """Measuring-tool extension data, one-to-one with a core Asset. + + Identity lives on the Asset (assetnumber = gage tag, serialnumber = vendor + serial, gaugelabreference identifier). This row carries only the metrology + domain fields: the tool type and the calibration lifecycle. + """ + __tablename__ = 'measuringtools' + + measuringtoolid = db.Column(db.Integer, primary_key=True) + + # Link to the core asset (one extension row per asset). + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True, + ) + + measuringtooltypeid = db.Column( + db.Integer, + db.ForeignKey('measuringtooltypes.measuringtooltypeid'), + nullable=True, + ) + + # Calibration lifecycle. Status is DERIVED from nextcalibrationdate, so + # none of these columns store a status. + calibrationintervaldays = db.Column(db.Integer, nullable=True) + lastcalibrationdate = db.Column(db.Date, nullable=True) + nextcalibrationdate = db.Column(db.Date, nullable=True) + calibrationprovider = db.Column(db.String(150), nullable=True) + + notes = db.Column(db.Text, nullable=True) + + asset = db.relationship( + 'Asset', + backref=db.backref('measuringtool', uselist=False, lazy='joined'), + ) + measuringtooltype = db.relationship('MeasuringToolType', backref='tools') + + __table_args__ = ( + db.Index('idx_measuringtool_type', 'measuringtooltypeid'), + ) + + def __repr__(self): + return f"" + + def calibrationstatus(self, today=None): + return derive_status(self.nextcalibrationdate, today) + + def to_dict(self, today=None): + """Extension dict with the type name and derived calibration status.""" + result = super().to_dict() + # BaseModel.to_dict only isoformats datetime, not plain date columns. + # Emit calibration dates as 'YYYY-MM-DD' so the frontend parses them + # the same way it parses warranty dates. + for field in ('lastcalibrationdate', 'nextcalibrationdate'): + value = getattr(self, field) + result[field] = value.isoformat() if value else None + if self.measuringtooltype: + result['measuringtooltypename'] = self.measuringtooltype.name + result['measuringtooltypecolor'] = self.measuringtooltype.color + status = self.calibrationstatus(today) + result['calibrationstatus'] = status + result['calibrationstatuscolor'] = STATUS_COLORS.get( + status, STATUS_COLORS['unknown']) + return result diff --git a/plugins/measuringtools/plugin.py b/plugins/measuringtools/plugin.py new file mode 100644 index 00000000..5a08e3fb --- /dev/null +++ b/plugins/measuringtools/plugin.py @@ -0,0 +1,179 @@ +"""Measuring-tools plugin main class. + +The first plugin built on the matured framework scaffold (ADR-005). It owns two +tables (measuringtooltypes, measuringtools), a blueprint under /api/measuringtools, +a sidebar entry, and a calibration report card. It seeds its own asset type and +a set of starter tool types on install. +""" + +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 shopdb.api import db, AssetType + +from .api import measuringtools_bp +from .models import MeasuringTool, MeasuringToolType + +logger = logging.getLogger(__name__) + +# Starter tool types seeded on install: (name, description, color). +# Colors are drawn from the shared frontend PALETTE (utils/colorStyle.js). +STARTER_TYPES = [ + ('Caliper', 'Vernier / digital caliper', '#14abef'), + ('Micrometer', 'Outside / inside / depth micrometer', '#2dce89'), + ('Thread Gage', 'Go / no-go thread gage', '#fb6340'), + ('Bore Gage', 'Bore / hole diameter gage', '#7934f3'), + ('Height Gage', 'Height / vertical measuring gage', '#ffc107'), + ('Indicator', 'Dial / test indicator', '#11cdef'), + ('Gage Block Set', 'Reference gage block set', '#e83e8c'), + ('Other', 'Other measuring tool', '#6c757d'), +] + + +class MeasuringToolsPlugin(BasePlugin): + """Metrology and inspection instruments (calibration lifecycle).""" + + 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', 'measuringtools'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', 'Metrology and inspection instruments'), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.6.0,<1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/measuringtools'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + return measuringtools_bp + + def get_models(self) -> List[Type]: + return [MeasuringTool, MeasuringToolType] + + def get_navigation_items(self) -> List[Dict]: + return [ + { + 'name': 'Measuring Tools', + 'icon': 'ruler', + 'route': '/measuringtools', + 'position': 22, + }, + ] + + def get_reports(self) -> List[Dict]: + return [ + { + 'id': 'calibration', + 'name': 'Calibration Due', + 'description': 'Measuring tools bucketed by calibration status', + 'category': 'compliance', + 'route': '/reports/calibration', + }, + ] + + def get_config_schema(self) -> List[Dict]: + # No external credentials or endpoints: calibration is tracked by hand, + # so the setup wizard shows nothing to configure. Documented in the + # plugin guide as the intentional empty-schema case. + return [] + + def get_settings_cards(self) -> List[Dict]: + # ADR-010 pilot. Contributes the Measuring Tools settings card that used + # to be hardcoded in the core settingsNav.js catalog. + return [ + { + 'group': 'Measuring Tools', + 'to': '/settings/measuringtooltypes', + 'icon': 'ruler', + 'title': 'Measuring Tool Types', + 'description': 'Manage measuring-tool subtypes (caliper, ' + 'micrometer, thread gage...) + map colors', + 'position': 22, + }, + ] + + def get_asset_presentation(self) -> List[Dict]: + # ADR-010 pilot. Tells core how to render + link the measuring_tool + # asset type in global-search rows and cross-links. + # The consumer only substitutes {assetid} (search/cross-link rows carry + # the core asset id, not the extension id), so link through the by-asset + # resolver route rather than the id-keyed detail route. + return [ + { + 'assettype': 'measuring_tool', + 'icon': 'ruler', + 'label': 'Measuring Tool', + 'route': '/measuringtools/by-asset/{assetid}', + }, + ] + + def get_map_overlays(self) -> List[Dict]: + # ADR-010 pilot. Declares a calibration-due badge overlay for the + # shop-floor map; the map fetches the endpoint to decorate markers. + return [ + { + 'id': 'calibration-due', + 'label': 'Calibration due', + 'endpoint': '/api/measuringtools/map-overlay', + 'style': 'badge', + 'legend': True, + }, + ] + + def init_app(self, app: Flask, db_instance) -> None: + logger.info(f"Measuring-tools plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + with app.app_context(): + self._ensure_asset_type() + self._ensure_starter_types() + db.session.commit() + logger.info("Measuring-tools plugin installed") + + def _ensure_asset_type(self) -> None: + existing = AssetType.query.filter_by(assettype='measuring_tool').first() + if not existing: + db.session.add(AssetType( + assettype='measuring_tool', + pluginname='measuringtools', + tablename='measuringtools', + description='Metrology and inspection instruments (gauges, ' + 'calipers, thread gages, bore gages, ...)', + icon='ruler', + )) + logger.debug("Created asset type: measuring_tool") + db.session.commit() + + def _ensure_starter_types(self) -> None: + for name, description, color in STARTER_TYPES: + if not MeasuringToolType.query.filter_by(name=name).first(): + db.session.add(MeasuringToolType( + name=name, description=description, color=color)) + logger.debug(f"Created measuring-tool type: {name}") + db.session.commit() + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('measuringtools.view', 'View measuring tools', 'measuringtools'), + ('measuringtools.create', 'Create measuring tools', 'measuringtools'), + ('measuringtools.edit', 'Edit measuring tools', 'measuringtools'), + ('measuringtools.delete', 'Delete measuring tools', 'measuringtools'), + ] diff --git a/plugins/network/__init__.py b/plugins/network/__init__.py new file mode 100644 index 00000000..8fd878c9 --- /dev/null +++ b/plugins/network/__init__.py @@ -0,0 +1,5 @@ +"""Network plugin for ShopDB.""" + +from .plugin import NetworkPlugin + +__all__ = ['NetworkPlugin'] diff --git a/plugins/network/api/__init__.py b/plugins/network/api/__init__.py new file mode 100644 index 00000000..aad02b4b --- /dev/null +++ b/plugins/network/api/__init__.py @@ -0,0 +1,5 @@ +"""Network plugin API.""" + +from .routes import network_bp + +__all__ = ['network_bp'] diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py new file mode 100644 index 00000000..751ae659 --- /dev/null +++ b/plugins/network/api/routes.py @@ -0,0 +1,1029 @@ +"""Network plugin API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import db, Asset, AssetType, Vendor, Communication, CommunicationType, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query + +from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN + +from shopdb.api import require_permission, apply_import_timestamps + +network_bp = Blueprint('network', __name__) + + +def _primary_ip(assetid): + """The asset's primary IP address string, or None. Mirrors the printer + route: a device's IP lives in a Communication row, not on the extension.""" + comm = (Communication.query.filter_by(assetid=assetid, isprimary=True).first() + or Communication.query.filter_by(assetid=assetid).first()) + return comm.ipaddress if comm else None + + +def _upsert_primary_ip(asset, ip): + """Create/update/clear the asset's primary IP Communication from an + ipaddress string. No-op if the IP CommunicationType is not seeded.""" + ip = (ip or '').strip() + if not ip: + return + comm = Communication.query.filter_by(assetid=asset.assetid, isprimary=True).first() + if comm: + comm.ipaddress = ip + return + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + if ip_comtype: + db.session.add(Communication( + assetid=asset.assetid, comtypeid=ip_comtype.comtypeid, + ipaddress=ip, isprimary=True)) + + +# ============================================================================= +# Network Device Types +# ============================================================================= + +@network_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_network_device_types(): + """List all network device types.""" + page, per_page = get_pagination_params(request) + + query = NetworkDeviceType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(NetworkDeviceType.isactive == True) + + if search := request.args.get('search'): + query = query.filter(NetworkDeviceType.networkdevicetype.ilike(f'%{search}%')) + + query = query.order_by(NetworkDeviceType.networkdevicetype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@network_bp.route('/types/', methods=['GET']) +@jwt_required(optional=True) +def get_network_device_type(type_id: int): + """Get a single network device type.""" + t = db.session.get(NetworkDeviceType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@network_bp.route('/types', methods=['POST']) +@jwt_required() +@require_permission('network.create') +def create_network_device_type(): + """Create a new network device type.""" + data = request.get_json() + + if not data or not data.get('networkdevicetype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'networkdevicetype is required') + + existing = NetworkDeviceType.query.filter_by(networkdevicetype=data['networkdevicetype']).first() + if existing: + if not existing.isactive: + existing.isactive = True + for key in ('description', 'icon', 'color'): + if data.get(key) is not None: + setattr(existing, key, data[key]) + db.session.commit() + return success_response(existing.to_dict(), message='Reactivated existing type') + return error_response( + ErrorCodes.CONFLICT, + f"Network device type '{data['networkdevicetype']}' already exists", + http_code=409 + ) + + t = NetworkDeviceType( + networkdevicetype=data['networkdevicetype'], + description=data.get('description'), + icon=data.get('icon'), color=data.get('color') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Network device type created', http_code=201) + + +@network_bp.route('/types/', methods=['PUT']) +@jwt_required() +@require_permission('network.edit') +def update_network_device_type(type_id: int): + """Update a network device type.""" + t = db.session.get(NetworkDeviceType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device type with ID {type_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'networkdevicetype' in data and data['networkdevicetype'] != t.networkdevicetype: + if NetworkDeviceType.query.filter_by(networkdevicetype=data['networkdevicetype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Network device type '{data['networkdevicetype']}' already exists", + http_code=409 + ) + + for key in ['networkdevicetype', 'description', 'icon', 'color', 'isactive']: + if key in data: + setattr(t, key, data[key]) + + db.session.commit() + return success_response(t.to_dict(), message='Network device type updated') + + +@network_bp.route('/types/', methods=['DELETE']) +@jwt_required() +@require_permission('network.delete') +def delete_network_device_type(type_id: int): + """Delete a network device type. Refused if any device still uses it.""" + t = db.session.get(NetworkDeviceType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Network device type not found', http_code=404) + inuse = NetworkDevice.query.filter_by(networkdevicetypeid=type_id).count() + if inuse: + return error_response(ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} device(s) still use this type", http_code=409) + db.session.delete(t) + db.session.commit() + return success_response(message='Network device type deleted') + + +# ============================================================================= +# Network Devices CRUD +# ============================================================================= + +@network_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_network_devices(): + """ + List all network devices with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status + - search: Search by asset number, name, or hostname + - type_id: Filter by network device type ID + - vendor_id: Filter by vendor ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + - poe: Filter by PoE capability (true/false) + - managed: Filter by managed status (true/false) + """ + page, per_page = get_pagination_params(request) + + # Join NetworkDevice with Asset + query = db.session.query(NetworkDevice).join(Asset) + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Exact-match natural-key lookup for idempotent import (asset number). + if exactassetnumber := request.args.get('assetnumber'): + query = query.filter(Asset.assetnumber == exactassetnumber) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%'), + NetworkDevice.hostname.ilike(f'%{search}%') + ) + ) + + # Type filter + if type_id := request.args.get('typeid', request.args.get('type_id')): + query = query.filter(NetworkDevice.networkdevicetypeid == int(type_id)) + + # Vendor filter + if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')): + query = query.filter(NetworkDevice.vendorid == int(vendor_id)) + + # Location filter + if location_id := request.args.get('locationid', request.args.get('location_id')): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # PoE filter + if poe := request.args.get('poe'): + query = query.filter(NetworkDevice.ispoe == (poe.lower() == 'true')) + + # Managed filter + if managed := request.args.get('managed'): + query = query.filter(NetworkDevice.ismanaged == (managed.lower() == 'true')) + + # Sorting + sort_by = request.args.get('sort', 'hostname') + sort_dir = request.args.get('dir', 'asc') + + if sort_by == 'hostname': + col = NetworkDevice.hostname + elif sort_by == 'assetnumber': + col = Asset.assetnumber + elif sort_by == 'name': + col = Asset.name + else: + col = NetworkDevice.hostname + + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + + items, total = paginate_query(query, page, per_page) + + # Build response with both asset and network device data + data = [] + for netdev in items: + item = netdev.asset.to_dict() if netdev.asset else {} + item['networkdevice'] = netdev.to_dict() + item['ipaddress'] = _primary_ip(netdev.assetid) + data.append(item) + + return paginated_response(data, page, per_page, total) + + +@network_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_network_device(device_id: int): + """Get a single network device with full details.""" + netdev = db.session.get(NetworkDevice, device_id) + + if not netdev: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device with ID {device_id} not found', + http_code=404 + ) + + result = netdev.asset.to_dict() if netdev.asset else {} + result['networkdevice'] = netdev.to_dict() + result['ipaddress'] = _primary_ip(netdev.assetid) + + return success_response(result) + + +@network_bp.route('/by-asset/', methods=['GET']) +@jwt_required(optional=True) +def get_network_device_by_asset(asset_id: int): + """Get network device data by asset ID.""" + netdev = NetworkDevice.query.filter_by(assetid=asset_id).first() + + if not netdev: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device for asset {asset_id} not found', + http_code=404 + ) + + result = netdev.asset.to_dict() if netdev.asset else {} + result['networkdevice'] = netdev.to_dict() + result['ipaddress'] = _primary_ip(netdev.assetid) + + return success_response(result) + + +@network_bp.route('/by-hostname/', methods=['GET']) +@jwt_required(optional=True) +def get_network_device_by_hostname(hostname: str): + """Get network device by hostname.""" + netdev = NetworkDevice.query.filter_by(hostname=hostname).first() + + if not netdev: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device with hostname {hostname} not found', + http_code=404 + ) + + result = netdev.asset.to_dict() if netdev.asset else {} + result['networkdevice'] = netdev.to_dict() + result['ipaddress'] = _primary_ip(netdev.assetid) + + return success_response(result) + + +@network_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('network.create') +def create_network_device(): + """ + Create new network device (creates both Asset and NetworkDevice records). + + Required fields: + - assetnumber: Business identifier + + Optional fields: + - name, serialnumber, statusid, locationid, businessunitid + - networkdevicetypeid, vendorid, hostname + - firmwareversion, portcount, ispoe, ismanaged, rackunit + - mapx, mapy, notes + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Check for duplicate hostname + if data.get('hostname'): + if NetworkDevice.query.filter_by(hostname=data['hostname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Network device with hostname '{data['hostname']}' already exists", + http_code=409 + ) + + # Get network device asset type + network_type = AssetType.query.filter_by(assettype='network_device').first() + if not network_type: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Network device asset type not found. Plugin may not be properly installed.', + http_code=500 + ) + + # Create the core asset + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + serialnumber=data.get('serialnumber'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), + assettypeid=network_type.assettypeid, + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapx=data.get('mapx'), + mapy=data.get('mapy'), + notes=data.get('notes') + ) + + db.session.add(asset) + db.session.flush() # Get the assetid + + # Create the network device extension + netdev = NetworkDevice( + assetid=asset.assetid, + networkdevicetypeid=data.get('networkdevicetypeid'), + vendorid=data.get('vendorid'), + hostname=data.get('hostname'), + firmwareversion=data.get('firmwareversion'), + portcount=data.get('portcount'), + ispoe=data.get('ispoe', False), + ismanaged=data.get('ismanaged', False), + rackunit=data.get('rackunit') + ) + + db.session.add(netdev) + db.session.flush() + + # Attach the primary IP as a Communication when supplied (a device's IP is + # not a NetworkDevice column). Mirrors the printer create route. + if data.get('ipaddress'): + _upsert_primary_ip(asset, data['ipaddress']) + + # Preserve legacy timestamps in import mode (no-op otherwise) + apply_import_timestamps(asset, data) + + # Audit log + AuditLog.log('created', 'NetworkDevice', entityid=netdev.networkdeviceid, + entityname=data.get('hostname') or data['assetnumber']) + + db.session.commit() + + result = asset.to_dict() + result['networkdevice'] = netdev.to_dict() + result['ipaddress'] = _primary_ip(asset.assetid) + + return success_response(result, message='Network device created', http_code=201) + + +@network_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('network.edit') +def update_network_device(device_id: int): + """Update network device (both Asset and NetworkDevice records).""" + netdev = db.session.get(NetworkDevice, device_id) + + if not netdev: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device with ID {device_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + asset = netdev.asset + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Check for conflicting hostname + if 'hostname' in data and data['hostname'] != netdev.hostname: + existing = NetworkDevice.query.filter_by(hostname=data['hostname']).first() + if existing and existing.networkdeviceid != device_id: + return error_response( + ErrorCodes.CONFLICT, + f"Network device with hostname '{data['hostname']}' already exists", + http_code=409 + ) + + # Track changes for audit log + changes = {} + + # Update asset fields + asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference', + 'maintenancereference', 'statusid', + 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] + for key in asset_fields: + if key in data: + old_val = getattr(asset, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(asset, key, data[key]) + + # Update network device fields + netdev_fields = ['networkdevicetypeid', 'vendorid', 'hostname', + 'firmwareversion', 'portcount', 'ispoe', 'ismanaged', 'rackunit'] + for key in netdev_fields: + if key in data: + old_val = getattr(netdev, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(netdev, key, data[key]) + + # Audit log if there were changes + if changes: + AuditLog.log('updated', 'NetworkDevice', entityid=netdev.networkdeviceid, + entityname=netdev.hostname or asset.assetnumber, changes=changes) + + # Upsert the primary IP Communication when an ipaddress is supplied. + if 'ipaddress' in data: + _upsert_primary_ip(asset, data['ipaddress']) + + apply_import_timestamps(asset, data) + db.session.commit() + + result = asset.to_dict() + result['networkdevice'] = netdev.to_dict() + result['ipaddress'] = _primary_ip(asset.assetid) + + return success_response(result, message='Network device updated') + + +@network_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('network.delete') +def delete_network_device(device_id: int): + """Delete (soft delete) network device.""" + netdev = db.session.get(NetworkDevice, device_id) + + if not netdev: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device with ID {device_id} not found', + http_code=404 + ) + + # Soft delete the asset + netdev.asset.isactive = False + + # Audit log + AuditLog.log('deleted', 'NetworkDevice', entityid=netdev.networkdeviceid, + entityname=netdev.hostname or netdev.asset.assetnumber) + + db.session.commit() + + return success_response(message='Network device deleted') + + +# ============================================================================= +# Dashboard +# ============================================================================= + +@network_bp.route('/dashboard/summary', methods=['GET']) +@jwt_required(optional=True) +def dashboard_summary(): + """Get network device dashboard summary data.""" + # Total active network devices + total = db.session.query(NetworkDevice).join(Asset).filter( + Asset.isactive == True + ).count() + + # Count by device type + by_type = db.session.query( + NetworkDeviceType.networkdevicetype, + db.func.count(NetworkDevice.networkdeviceid) + ).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid + ).join(Asset, Asset.assetid == NetworkDevice.assetid + ).filter(Asset.isactive == True + ).group_by(NetworkDeviceType.networkdevicetype + ).all() + + # Count by vendor + by_vendor = db.session.query( + Vendor.vendor, + db.func.count(NetworkDevice.networkdeviceid) + ).join(NetworkDevice, NetworkDevice.vendorid == Vendor.vendorid + ).join(Asset, Asset.assetid == NetworkDevice.assetid + ).filter(Asset.isactive == True + ).group_by(Vendor.vendor + ).all() + + # Count PoE vs non-PoE + poe_count = db.session.query(NetworkDevice).join(Asset).filter( + Asset.isactive == True, + NetworkDevice.ispoe == True + ).count() + + return success_response({ + 'total': total, + 'bytype': [{'type': t, 'count': c} for t, c in by_type], + 'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor], + 'poe': poe_count, + 'nonpoe': total - poe_count + }) + + +# ============================================================================= +# VLANs +# ============================================================================= + +@network_bp.route('/vlans', methods=['GET']) +@jwt_required(optional=True) +def list_vlans(): + """List all VLANs with filtering and pagination.""" + page, per_page = get_pagination_params(request) + + query = VLAN.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(VLAN.isactive == True) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + VLAN.name.ilike(f'%{search}%'), + VLAN.description.ilike(f'%{search}%'), + db.cast(VLAN.vlannumber, db.String).ilike(f'%{search}%') + ) + ) + + # Type filter + if vlan_type := request.args.get('type'): + query = query.filter(VLAN.vlantype == vlan_type) + + query = query.order_by(VLAN.vlannumber) + + items, total = paginate_query(query, page, per_page) + data = [v.to_dict() for v in items] + + return paginated_response(data, page, per_page, total) + + +@network_bp.route('/vlans/', methods=['GET']) +@jwt_required(optional=True) +def get_vlan(vlan_id: int): + """Get a single VLAN with its subnets.""" + vlan = db.session.get(VLAN, vlan_id) + + if not vlan: + return error_response( + ErrorCodes.NOT_FOUND, + f'VLAN with ID {vlan_id} not found', + http_code=404 + ) + + result = vlan.to_dict() + # Include associated subnets + result['subnets'] = [s.to_dict() for s in vlan.subnets.filter_by(isactive=True).all()] + + return success_response(result) + + +@network_bp.route('/vlans', methods=['POST']) +@jwt_required() +@require_permission('network.create') +def create_vlan(): + """Create a new VLAN.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('vlannumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'vlannumber is required') + if not data.get('name'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') + + # Check for duplicate VLAN number + if VLAN.query.filter_by(vlannumber=data['vlannumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"VLAN {data['vlannumber']} already exists", + http_code=409 + ) + + vlan = VLAN( + vlannumber=data['vlannumber'], + name=data['name'], + description=data.get('description'), + vlantype=data.get('vlantype') + ) + + db.session.add(vlan) + db.session.flush() + + # Audit log + AuditLog.log('created', 'VLAN', entityid=vlan.vlanid, + entityname=f"VLAN {vlan.vlannumber} - {vlan.name}") + + db.session.commit() + + return success_response(vlan.to_dict(), message='VLAN created', http_code=201) + + +@network_bp.route('/vlans/', methods=['PUT']) +@jwt_required() +@require_permission('network.edit') +def update_vlan(vlan_id: int): + """Update a VLAN.""" + vlan = db.session.get(VLAN, vlan_id) + + if not vlan: + return error_response( + ErrorCodes.NOT_FOUND, + f'VLAN with ID {vlan_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Check for conflicting VLAN number + if 'vlannumber' in data and data['vlannumber'] != vlan.vlannumber: + if VLAN.query.filter_by(vlannumber=data['vlannumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"VLAN {data['vlannumber']} already exists", + http_code=409 + ) + + # Track changes for audit log + changes = {} + for key in ['vlannumber', 'name', 'description', 'vlantype', 'isactive']: + if key in data: + old_val = getattr(vlan, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(vlan, key, data[key]) + + # Audit log if there were changes + if changes: + AuditLog.log('updated', 'VLAN', entityid=vlan.vlanid, + entityname=f"VLAN {vlan.vlannumber} - {vlan.name}", changes=changes) + + db.session.commit() + return success_response(vlan.to_dict(), message='VLAN updated') + + +@network_bp.route('/vlans/', methods=['DELETE']) +@jwt_required() +@require_permission('network.delete') +def delete_vlan(vlan_id: int): + """Delete (soft delete) a VLAN.""" + vlan = db.session.get(VLAN, vlan_id) + + if not vlan: + return error_response( + ErrorCodes.NOT_FOUND, + f'VLAN with ID {vlan_id} not found', + http_code=404 + ) + + # Check if VLAN has associated subnets + if vlan.subnets.filter_by(isactive=True).count() > 0: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Cannot delete VLAN with associated subnets', + http_code=400 + ) + + vlan.isactive = False + + # Audit log + AuditLog.log('deleted', 'VLAN', entityid=vlan.vlanid, + entityname=f"VLAN {vlan.vlannumber} - {vlan.name}") + + db.session.commit() + + return success_response(message='VLAN deleted') + + +# ============================================================================= +# Subnets +# ============================================================================= + +@network_bp.route('/subnets', methods=['GET']) +@jwt_required(optional=True) +def list_subnets(): + """List all subnets with filtering and pagination.""" + page, per_page = get_pagination_params(request) + + query = Subnet.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Subnet.isactive == True) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Subnet.cidr.ilike(f'%{search}%'), + Subnet.name.ilike(f'%{search}%'), + Subnet.description.ilike(f'%{search}%') + ) + ) + + # VLAN filter + if vlan_id := request.args.get('vlanid'): + query = query.filter(Subnet.vlanid == int(vlan_id)) + + # Location filter + if location_id := request.args.get('locationid'): + query = query.filter(Subnet.locationid == int(location_id)) + + # Type filter + if subnet_type := request.args.get('type'): + query = query.filter(Subnet.subnettype == subnet_type) + + query = query.order_by(Subnet.cidr) + + items, total = paginate_query(query, page, per_page) + data = [s.to_dict() for s in items] + + return paginated_response(data, page, per_page, total) + + +@network_bp.route('/subnets/', methods=['GET']) +@jwt_required(optional=True) +def get_subnet(subnet_id: int): + """Get a single subnet plus the network devices whose primary IP falls in + its CIDR range.""" + subnet = db.session.get(Subnet, subnet_id) + + if not subnet: + return error_response( + ErrorCodes.NOT_FOUND, + f'Subnet with ID {subnet_id} not found', + http_code=404 + ) + + data = subnet.to_dict() + data['devices'] = _devices_in_subnet(subnet.cidr) + return success_response(data) + + +def _devices_in_subnet(cidr): + """ANY asset (PC, printer, network device, ...) whose primary IP falls in + cidr. A subnet is cross-type, so this matches on the core Communication + + Asset tables, not just network devices. Matching is in Python because the IP + lives in a Communication row, not a column.""" + import ipaddress + from shopdb.api import Asset, AssetType, Communication + try: + net = ipaddress.ip_network(cidr, strict=False) + except (ValueError, TypeError): + return [] + rows = (Communication.query + .join(Asset, Communication.assetid == Asset.assetid) + .join(AssetType, Asset.assettypeid == AssetType.assettypeid) + .filter(Communication.isprimary == True, + Communication.ipaddress.isnot(None)) + .with_entities(Asset.assetid, Asset.assetnumber, Asset.name, + AssetType.assettype, Communication.ipaddress).all()) + result = [] + for assetid, assetnumber, name, assettype, ip in rows: + ip = (ip or '').strip() + if not ip: + continue + try: + if ipaddress.ip_address(ip) in net: + result.append({ + 'assetid': assetid, 'assetnumber': assetnumber, 'name': name, + 'assettype': assettype, 'ipaddress': ip, + 'url': _asset_detail_url(assettype, assetid), + }) + except (ValueError, TypeError): + continue + result.sort(key=lambda d: d['ipaddress']) + return result + + +def _asset_detail_url(assettype, assetid): + """Best-effort front-end detail path for an asset by type. Resolves the + per-type extension id lazily/guarded (subnet listings span plugins); returns + None when the plugin is absent so the frontend just shows the row.""" + try: + if assettype == 'network_device': + row = NetworkDevice.query.filter_by(assetid=assetid).first() + return f'/network/{row.networkdeviceid}' if row else None + if assettype == 'printer': + from plugins.printers.models import Printer + row = Printer.query.filter_by(assetid=assetid).first() + return f'/printers/{row.printerid}' if row else None + if assettype == 'computer': + from plugins.computers.models import Computer + row = Computer.query.filter_by(assetid=assetid).first() + return f'/pcs/{row.computerid}' if row else None + except ImportError: + return None + return None + + +@network_bp.route('/subnets', methods=['POST']) +@jwt_required() +@require_permission('network.create') +def create_subnet(): + """Create a new subnet.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('cidr'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr is required') + if not data.get('name'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') + + # Validate CIDR format (basic check) + cidr = data['cidr'] + if '/' not in cidr: + return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr must be in CIDR notation (e.g., 10.1.1.0/24)') + + # Check for duplicate CIDR + if Subnet.query.filter_by(cidr=cidr).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Subnet {cidr} already exists", + http_code=409 + ) + + # Validate VLAN if provided + if data.get('vlanid'): + if not db.session.get(VLAN, data['vlanid']): + return error_response( + ErrorCodes.VALIDATION_ERROR, + f"VLAN with ID {data['vlanid']} not found" + ) + + subnet = Subnet( + cidr=cidr, + name=data['name'], + description=data.get('description'), + gatewayip=data.get('gatewayip'), + subnetmask=data.get('subnetmask'), + networkaddress=data.get('networkaddress'), + broadcastaddress=data.get('broadcastaddress'), + vlanid=data.get('vlanid'), + subnettype=data.get('subnettype'), + locationid=data.get('locationid'), + dhcpenabled=data.get('dhcpenabled', True), + dhcprangestart=data.get('dhcprangestart'), + dhcprangeend=data.get('dhcprangeend'), + dns1=data.get('dns1'), + dns2=data.get('dns2') + ) + + db.session.add(subnet) + db.session.flush() + + # Audit log + AuditLog.log('created', 'Subnet', entityid=subnet.subnetid, + entityname=f"{subnet.cidr} - {subnet.name}") + + db.session.commit() + + return success_response(subnet.to_dict(), message='Subnet created', http_code=201) + + +@network_bp.route('/subnets/', methods=['PUT']) +@jwt_required() +@require_permission('network.edit') +def update_subnet(subnet_id: int): + """Update a subnet.""" + subnet = db.session.get(Subnet, subnet_id) + + if not subnet: + return error_response( + ErrorCodes.NOT_FOUND, + f'Subnet with ID {subnet_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Check for conflicting CIDR + if 'cidr' in data and data['cidr'] != subnet.cidr: + if Subnet.query.filter_by(cidr=data['cidr']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Subnet {data['cidr']} already exists", + http_code=409 + ) + + allowed_fields = ['cidr', 'name', 'description', 'gatewayip', 'subnetmask', + 'networkaddress', 'broadcastaddress', 'vlanid', 'subnettype', + 'locationid', 'dhcpenabled', 'dhcprangestart', 'dhcprangeend', + 'dns1', 'dns2', 'isactive'] + + # Track changes for audit log + changes = {} + for key in allowed_fields: + if key in data: + old_val = getattr(subnet, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(subnet, key, data[key]) + + # Audit log if there were changes + if changes: + AuditLog.log('updated', 'Subnet', entityid=subnet.subnetid, + entityname=f"{subnet.cidr} - {subnet.name}", changes=changes) + + db.session.commit() + return success_response(subnet.to_dict(), message='Subnet updated') + + +@network_bp.route('/subnets/', methods=['DELETE']) +@jwt_required() +@require_permission('network.delete') +def delete_subnet(subnet_id: int): + """Delete (soft delete) a subnet.""" + subnet = db.session.get(Subnet, subnet_id) + + if not subnet: + return error_response( + ErrorCodes.NOT_FOUND, + f'Subnet with ID {subnet_id} not found', + http_code=404 + ) + + subnet.isactive = False + + # Audit log + AuditLog.log('deleted', 'Subnet', entityid=subnet.subnetid, + entityname=f"{subnet.cidr} - {subnet.name}") + + db.session.commit() + + return success_response(message='Subnet deleted') diff --git a/plugins/network/manifest.json b/plugins/network/manifest.json new file mode 100644 index 00000000..fa56c2b9 --- /dev/null +++ b/plugins/network/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "network", + "version": "1.0.0", + "description": "Network device management plugin for switches, APs, cameras, and IDFs", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/network", + "provides": { + "asset_type": "network_device", + "features": [ + "network_device_tracking", + "port_management", + "firmware_tracking", + "poe_monitoring" + ] + }, + "settings": { + "enable_snmp_polling": false, + "snmp_community": "public" + } +} diff --git a/plugins/network/migrations/env.py b/plugins/network/migrations/env.py new file mode 100644 index 00000000..03bc8d76 --- /dev/null +++ b/plugins/network/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the network 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_network. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'network' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/network/migrations/script.py.mako b/plugins/network/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/network/migrations/script.py.mako @@ -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"} diff --git a/plugins/network/migrations/versions/0001_network_anchor.py b/plugins/network/migrations/versions/0001_network_anchor.py new file mode 100644 index 00000000..9ded03c1 --- /dev/null +++ b/plugins/network/migrations/versions/0001_network_anchor.py @@ -0,0 +1,28 @@ +"""network 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_network. From this +anchor forward, new network 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 = 'network0001anchor' +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 diff --git a/plugins/network/models/__init__.py b/plugins/network/models/__init__.py new file mode 100644 index 00000000..131748d9 --- /dev/null +++ b/plugins/network/models/__init__.py @@ -0,0 +1,11 @@ +"""Network plugin models.""" + +from .network_device import NetworkDevice, NetworkDeviceType +from .subnet import Subnet, VLAN + +__all__ = [ + 'NetworkDevice', + 'NetworkDeviceType', + 'Subnet', + 'VLAN', +] diff --git a/plugins/network/models/network_device.py b/plugins/network/models/network_device.py new file mode 100644 index 00000000..1ab6f73b --- /dev/null +++ b/plugins/network/models/network_device.py @@ -0,0 +1,120 @@ +"""Network device plugin models.""" + +from shopdb.api import db, BaseModel + + +class NetworkDeviceType(BaseModel): + """ + Network device type classification. + + Examples: Switch, Router, Access Point, Camera, IDF, Firewall, etc. + """ + __tablename__ = 'networkdevicetypes' + + networkdevicetypeid = db.Column(db.Integer, primary_key=True) + networkdevicetype = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + color = db.Column(db.String(20), comment='CSS color for UI/map markers') + + def __repr__(self): + return f"" + + +class NetworkDevice(BaseModel): + """ + Network device-specific extension data. + + Links to core Asset table via assetid. + Stores network device-specific fields like hostname, firmware, ports, etc. + """ + __tablename__ = 'networkdevices' + + networkdeviceid = db.Column(db.Integer, primary_key=True) + + # Link to core asset + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Network device classification + networkdevicetypeid = db.Column( + db.Integer, + db.ForeignKey('networkdevicetypes.networkdevicetypeid'), + nullable=True + ) + + # Vendor + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + + # Network identity + hostname = db.Column( + db.String(100), + comment='Network hostname' + ) + + # Firmware/software version + firmwareversion = db.Column(db.String(100), nullable=True) + + # Physical characteristics + portcount = db.Column( + db.Integer, + nullable=True, + comment='Number of ports (for switches)' + ) + + # Features + ispoe = db.Column( + db.Boolean, + default=False, + comment='Power over Ethernet capable' + ) + ismanaged = db.Column( + db.Boolean, + default=False, + comment='Managed device (SNMP, web interface, etc.)' + ) + + # For IDF/closet locations + rackunit = db.Column( + db.String(20), + nullable=True, + comment='Rack unit position (e.g., U1, U5)' + ) + + # Relationships + asset = db.relationship( + 'Asset', + backref=db.backref('network_device', uselist=False, lazy='joined') + ) + networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices') + vendor = db.relationship('Vendor', backref='network_devices') + + __table_args__ = ( + db.Index('idx_netdev_type', 'networkdevicetypeid'), + db.Index('idx_netdev_hostname', 'hostname'), + db.Index('idx_netdev_vendor', 'vendorid'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.networkdevicetype: + result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype + if self.vendor: + result['vendorname'] = self.vendor.vendor + + return result diff --git a/plugins/network/models/subnet.py b/plugins/network/models/subnet.py new file mode 100644 index 00000000..6a52b9f7 --- /dev/null +++ b/plugins/network/models/subnet.py @@ -0,0 +1,145 @@ +"""Subnet and VLAN models for network plugin.""" + +from shopdb.api import db, BaseModel + + +class VLAN(BaseModel): + """ + VLAN definition. + + Represents a virtual LAN for network segmentation. + """ + __tablename__ = 'vlans' + + vlanid = db.Column(db.Integer, primary_key=True) + vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number') + name = db.Column(db.String(100), nullable=False, comment='VLAN name') + description = db.Column(db.Text, nullable=True) + + # Optional classification + vlantype = db.Column( + db.String(50), + nullable=True, + comment='Type: data, voice, management, guest, etc.' + ) + + # Relationships + subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic') + + __table_args__ = ( + db.Index('idx_vlan_number', 'vlannumber'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary.""" + result = super().to_dict() + result['subnetcount'] = self.subnets.count() if self.subnets else 0 + return result + + +class Subnet(BaseModel): + """ + Subnet/IP network definition. + + Represents an IP subnet with optional VLAN association. + """ + __tablename__ = 'subnets' + + subnetid = db.Column(db.Integer, primary_key=True) + + # Network definition + cidr = db.Column( + db.String(18), + unique=True, + nullable=False, + comment='CIDR notation (e.g., 10.1.1.0/24)' + ) + name = db.Column(db.String(100), nullable=False, comment='Subnet name') + description = db.Column(db.Text, nullable=True) + + # Network details + gatewayip = db.Column( + db.String(15), + nullable=True, + comment='Default gateway IP address' + ) + subnetmask = db.Column( + db.String(15), + nullable=True, + comment='Subnet mask (e.g., 255.255.255.0)' + ) + networkaddress = db.Column( + db.String(15), + nullable=True, + comment='Network address (e.g., 10.1.1.0)' + ) + broadcastaddress = db.Column( + db.String(15), + nullable=True, + comment='Broadcast address (e.g., 10.1.1.255)' + ) + + # VLAN association + vlanid = db.Column( + db.Integer, + db.ForeignKey('vlans.vlanid'), + nullable=True + ) + + # Classification + subnettype = db.Column( + db.String(50), + nullable=True, + comment='Type: production, development, management, dmz, etc.' + ) + + # Location association + locationid = db.Column( + db.Integer, + db.ForeignKey('locations.locationid'), + nullable=True + ) + + # DHCP settings + dhcpenabled = db.Column(db.Boolean, default=True, comment='DHCP enabled for this subnet') + dhcprangestart = db.Column(db.String(15), nullable=True, comment='DHCP range start IP') + dhcprangeend = db.Column(db.String(15), nullable=True, comment='DHCP range end IP') + + # DNS settings + dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server') + dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server') + + # Relationships + location = db.relationship('Location', backref='subnets') + + __table_args__ = ( + db.Index('idx_subnet_cidr', 'cidr'), + db.Index('idx_subnet_vlan', 'vlanid'), + db.Index('idx_subnet_location', 'locationid'), + ) + + def __repr__(self): + return f"" + + @property + def vlan_number(self): + """Get the VLAN number.""" + return self.vlan.vlannumber if self.vlan else None + + def to_dict(self): + """Convert to dictionary with related data.""" + result = super().to_dict() + + # Add VLAN info + if self.vlan: + result['vlannumber'] = self.vlan.vlannumber + result['vlanname'] = self.vlan.name + + # Add location info + if self.location: + result['locationname'] = self.location.locationname + + return result diff --git a/plugins/network/plugin.py b/plugins/network/plugin.py new file mode 100644 index 00000000..903129c0 --- /dev/null +++ b/plugins/network/plugin.py @@ -0,0 +1,225 @@ +"""Network plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint +import click + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db, AssetType + +from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN +from .api import network_bp + +logger = logging.getLogger(__name__) + + +class NetworkPlugin(BasePlugin): + """ + Network plugin - manages network device assets. + + Network devices include switches, routers, access points, cameras, IDFs, etc. + Uses the new Asset architecture with NetworkDevice extension table. + """ + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + manifestpath = Path(__file__).parent / 'manifest.json' + if manifestpath.exists(): + with open(manifestpath, 'r') as f: + return json.load(f) + return {} + + @property + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'network'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Network device management for switches, APs, and cameras' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/network'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return network_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [NetworkDevice, NetworkDeviceType, Subnet, VLAN] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Network plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensure_asset_type() + self._ensure_network_device_types() + logger.info("Network plugin installed") + + def _ensure_asset_type(self) -> None: + """Ensure network_device asset type exists.""" + existing = AssetType.query.filter_by(assettype='network_device').first() + if not existing: + at = AssetType( + assettype='network_device', + pluginname='network', + tablename='networkdevices', + description='Network infrastructure devices (switches, APs, cameras, etc.)', + icon='network-wired' + ) + db.session.add(at) + logger.debug("Created asset type: network_device") + db.session.commit() + + def _ensure_network_device_types(self) -> None: + """Ensure basic network device types exist.""" + device_types = [ + ('Switch', 'Network switch', 'network-wired'), + ('Router', 'Network router', 'router'), + ('Access Point', 'Wireless access point', 'wifi'), + ('Firewall', 'Network firewall', 'shield'), + ('Camera', 'IP camera', 'video'), + ('IDF', 'Intermediate Distribution Frame/closet', 'box'), + ('MDF', 'Main Distribution Frame', 'building'), + ('Patch Panel', 'Patch panel', 'th'), + ('UPS', 'Uninterruptible power supply', 'battery'), + ('Other', 'Other network device', 'network-wired'), + ] + + for name, description, icon in device_types: + existing = NetworkDeviceType.query.filter_by(networkdevicetype=name).first() + if not existing: + ndt = NetworkDeviceType( + networkdevicetype=name, + description=description, + icon=icon + ) + db.session.add(ndt) + logger.debug(f"Created network device type: {name}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Network plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('network') + def networkcli(): + """Network plugin commands.""" + pass + + @networkcli.command('list-types') + def list_types(): + """List all network device types.""" + from flask import current_app + + with current_app.app_context(): + types = NetworkDeviceType.query.filter_by(isactive=True).all() + if not types: + click.echo('No network device types found.') + return + + click.echo('Network Device Types:') + for t in types: + click.echo(f" [{t.networkdevicetypeid}] {t.networkdevicetype}") + + @networkcli.command('stats') + def stats(): + """Show network device statistics.""" + from flask import current_app + from shopdb.api import Asset + + with current_app.app_context(): + total = db.session.query(NetworkDevice).join(Asset).filter( + Asset.isactive == True + ).count() + + click.echo(f"Total active network devices: {total}") + + # By type + by_type = db.session.query( + NetworkDeviceType.networkdevicetype, + db.func.count(NetworkDevice.networkdeviceid) + ).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid + ).join(Asset, Asset.assetid == NetworkDevice.assetid + ).filter(Asset.isactive == True + ).group_by(NetworkDeviceType.networkdevicetype + ).all() + + if by_type: + click.echo("\nBy Type:") + for t, c in by_type: + click.echo(f" {t}: {c}") + + @networkcli.command('find') + @click.argument('hostname') + def find_by_hostname(hostname): + """Find a network device by hostname.""" + from flask import current_app + + with current_app.app_context(): + netdev = NetworkDevice.query.filter( + NetworkDevice.hostname.ilike(f'%{hostname}%') + ).first() + + if not netdev: + click.echo(f'No network device found matching hostname: {hostname}') + return + + click.echo(f'Found: {netdev.hostname}') + click.echo(f' Asset: {netdev.asset.assetnumber}') + click.echo(f' Type: {netdev.networkdevicetype.networkdevicetype if netdev.networkdevicetype else "N/A"}') + click.echo(f' Firmware: {netdev.firmwareversion or "N/A"}') + click.echo(f' PoE: {"Yes" if netdev.ispoe else "No"}') + + return [networkcli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Network Status', + 'component': 'NetworkStatusWidget', + 'endpoint': '/api/network/dashboard/summary', + 'size': 'medium', + 'position': 7, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Network', + 'icon': 'network-wired', + 'route': '/network', + 'position': 18, + }, + ] + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('network.view', 'View network devices', 'network'), + ('network.create', 'Create network devices', 'network'), + ('network.edit', 'Edit network devices', 'network'), + ('network.delete', 'Delete network devices', 'network'), + ] diff --git a/plugins/notifications/__init__.py b/plugins/notifications/__init__.py new file mode 100644 index 00000000..edd7b58d --- /dev/null +++ b/plugins/notifications/__init__.py @@ -0,0 +1,5 @@ +"""Notifications plugin package.""" + +from .plugin import NotificationsPlugin + +__all__ = ['NotificationsPlugin'] diff --git a/plugins/notifications/api/__init__.py b/plugins/notifications/api/__init__.py new file mode 100644 index 00000000..d0385c2b --- /dev/null +++ b/plugins/notifications/api/__init__.py @@ -0,0 +1,5 @@ +"""Notifications plugin API.""" + +from .routes import notifications_bp + +__all__ = ['notifications_bp'] diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py new file mode 100644 index 00000000..e0006d7b --- /dev/null +++ b/plugins/notifications/api/routes.py @@ -0,0 +1,759 @@ +"""Notifications plugin API endpoints - adapted to existing schema.""" + +import hashlib +import os +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query + +from ..models import Notification, NotificationType + +from shopdb.api import require_permission + +notifications_bp = Blueprint('notifications', __name__) + +# Notification types whose multi-employee cards get split into one card per +# employee on the shopfloor dashboard. typecolor drives this (not typename), so +# recognition, training and recertification all fan out; every other type +# stays one card. +SPLIT_TYPECOLORS = frozenset({'recognition', 'training', 'recertification'}) + +# How long a card stays up on the shopfloor board when the creator does not set +# an explicit end time, by notification typecolor. +# recognition - clears at the next 8:00 AM Eastern (daily reset) +# recertification - stays up two weeks (employees have time to book the course) +EASTERN = ZoneInfo('America/New_York') +RECERTIFICATION_DAYS = 14 + + +def _next_eastern_time(after, hour, minute=0): + """Next hour:minute America/New_York strictly after `after` (naive UTC), + returned as naive UTC. Uses the tz database so it is correct across EST/EDT.""" + after_east = after.replace(tzinfo=timezone.utc).astimezone(EASTERN) + target = after_east.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0) + if target <= after_east: + target += timedelta(days=1) + return target.astimezone(timezone.utc).replace(tzinfo=None) + + +def _auto_endtime(ntype, starttime): + """Default display window for a notification with no explicit end time, from + the notification type's configured expiry rule: + 'dailytime' -> next expiryhour:expiryminute Eastern (daily reset) + 'duration' -> starttime + expirydays days + 'none' -> None (show indefinitely) + Falls back to the legacy typecolor rules when the expiry columns are unset, + so it is safe before/after the 7d03 migration.""" + mode = getattr(ntype, 'expirymode', None) or 'none' + if mode == 'dailytime': + hour = ntype.expiryhour if ntype.expiryhour is not None else 8 + return _next_eastern_time(starttime, hour, ntype.expiryminute or 0) + if mode == 'duration' and ntype.expirydays: + return starttime + timedelta(days=int(ntype.expirydays)) + if mode == 'none': + # legacy fallback for rule-bearing types created before the expiry columns + if getattr(ntype, 'typecolor', None) == 'recognition': + return _next_eastern_time(starttime, 8, 0) + if getattr(ntype, 'typecolor', None) == 'recertification': + return starttime + timedelta(days=RECERTIFICATION_DAYS) + return None + + +_EXPIRY_MODES = ('none', 'duration', 'dailytime') + + +def _apply_expiry_fields(t, data): + """Set expiry-rule columns on a NotificationType from request data. Only + touches fields that are present. Returns an error string, or None on ok.""" + if 'expirymode' in data: + mode = data.get('expirymode') or 'none' + if mode not in _EXPIRY_MODES: + return "expirymode must be one of: %s" % ", ".join(_EXPIRY_MODES) + t.expirymode = mode + if 'expirydays' in data: + v = data.get('expirydays') + if v in (None, ''): + t.expirydays = None + else: + try: + t.expirydays = int(v) + except (TypeError, ValueError): + return "expirydays must be an integer" + if t.expirydays < 1: + return "expirydays must be >= 1" + if 'expiryhour' in data: + v = data.get('expiryhour') + if v in (None, ''): + t.expiryhour = None + else: + try: + t.expiryhour = int(v) + except (TypeError, ValueError): + return "expiryhour must be an integer" + if not (0 <= t.expiryhour <= 23): + return "expiryhour must be 0-23" + if 'expiryminute' in data: + v = data.get('expiryminute') + try: + t.expiryminute = int(v) if v not in (None, '') else 0 + except (TypeError, ValueError): + return "expiryminute must be an integer" + if not (0 <= t.expiryminute <= 59): + return "expiryminute must be 0-59" + # cross-field consistency + mode = t.expirymode or 'none' + if mode == 'dailytime' and t.expiryhour is None: + t.expiryhour = 8 + if mode == 'duration' and not t.expirydays: + return "duration expiry requires expirydays >= 1" + return None + + +_DISPLAY_STYLES = ('standard', 'carousel', 'grid', 'banner') + + +def _apply_display_fields(t, data): + """Set shopfloor display-behavior columns on a NotificationType from request + data. Only touches fields that are present. Returns an error string, or None.""" + if 'splitperemployee' in data: + t.splitperemployee = bool(data.get('splitperemployee')) + if 'showemployeephoto' in data: + t.showemployeephoto = bool(data.get('showemployeephoto')) + if 'displaystyle' in data: + ds = data.get('displaystyle') or 'standard' + if ds not in _DISPLAY_STYLES: + return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES) + t.displaystyle = ds + return None + + +def _config_version(): + """Short hash of everything that changes the board's LAYOUT: per-type display + config plus an optional deploy stamp (SHOPFLOOR_BUILD env). The shopfloor + kiosks reload when this changes, so type/layout edits and frontend deploys + reach pages that are already open.""" + types = NotificationType.query.order_by(NotificationType.notificationtypeid).all() + parts = [ + "%s|%s|%s|%d|%d|%s|%s|%s|%d" % ( + t.notificationtypeid, t.typecolor, t.displaystyle, + int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)), + t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)), + ) + for t in types + ] + parts.append(os.environ.get('SHOPFLOOR_BUILD', '')) + return hashlib.md5('||'.join(parts).encode()).hexdigest()[:12] + + +def _employee_picture(sso): + """Resolved display photo URL for an SSO, via the shared employees-plugin + resolver so kiosk cards match EmployeeDetail in both directory modes + (self-hosted upload URL or external HR URL). None on any miss.""" + try: + from plugins.employees.api.routes import resolve_employee_photo_url + return resolve_employee_photo_url(sso) + except Exception: + return None + + +# ============================================================================= +# Notification Types +# ============================================================================= + +@notifications_bp.route('/types', methods=['GET']) +def list_notification_types(): + """List all notification types.""" + page, per_page = get_pagination_params(request) + + query = NotificationType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(NotificationType.isactive == True) + + query = query.order_by(NotificationType.typename) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@notifications_bp.route('/types', methods=['POST']) +@jwt_required() +@require_permission('notifications.create') +def create_notification_type(): + """Create a new notification type.""" + data = request.get_json() + + if not data or not data.get('typename'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'typename is required') + + if NotificationType.query.filter_by(typename=data['typename']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Notification type '{data['typename']}' already exists", + http_code=409 + ) + + t = NotificationType( + typename=data['typename'], + typedescription=data.get('typedescription') or data.get('description'), + typecolor=data.get('typecolor') or data.get('color', '#17a2b8') + ) + + err = _apply_expiry_fields(t, data) + if err: + return error_response(ErrorCodes.VALIDATION_ERROR, err) + err = _apply_display_fields(t, data) + if err: + return error_response(ErrorCodes.VALIDATION_ERROR, err) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Notification type created', http_code=201) + + +@notifications_bp.route('/types/', methods=['PUT', 'PATCH']) +@jwt_required() +@require_permission('notifications.create') +def update_notification_type(type_id: int): + """Update a notification type, including its auto-expiry rule.""" + t = db.session.get(NotificationType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, f'Notification type {type_id} not found', http_code=404) + + data = request.get_json() or {} + + if data.get('typename'): + dup = NotificationType.query.filter( + NotificationType.typename == data['typename'], + NotificationType.notificationtypeid != type_id + ).first() + if dup: + return error_response(ErrorCodes.CONFLICT, + f"Notification type '{data['typename']}' already exists", http_code=409) + t.typename = data['typename'] + if 'typedescription' in data or 'description' in data: + t.typedescription = data.get('typedescription') or data.get('description') + if 'typecolor' in data or 'color' in data: + t.typecolor = data.get('typecolor') or data.get('color') + if 'isactive' in data: + t.isactive = bool(data['isactive']) + + err = _apply_expiry_fields(t, data) + if err: + return error_response(ErrorCodes.VALIDATION_ERROR, err) + err = _apply_display_fields(t, data) + if err: + return error_response(ErrorCodes.VALIDATION_ERROR, err) + + db.session.commit() + return success_response(t.to_dict(), message='Notification type updated') + + +# ============================================================================= +# Notifications CRUD +# ============================================================================= + +@notifications_bp.route('', methods=['GET']) +def list_notifications(): + """ + List all notifications with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status (default: true) + - type_id: Filter by notification type ID + - current: Filter to currently active notifications only + - search: Search in notification text + """ + page, per_page = get_pagination_params(request) + + query = Notification.query + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Notification.isactive == True) + + # Type filter + if type_id := request.args.get('typeid', request.args.get('type_id')): + query = query.filter(Notification.notificationtypeid == int(type_id)) + + # Exact-match lookup for idempotent import. Notifications have no strong + # natural key; ticketnumber is the best available when a ticket is set. + if exactticket := request.args.get('ticketnumber'): + query = query.filter(Notification.ticketnumber == exactticket) + + # Current filter (active based on dates) + if request.args.get('current', 'false').lower() == 'true': + now = datetime.now(timezone.utc).replace(tzinfo=None) + query = query.filter( + Notification.starttime <= now, + db.or_( + Notification.endtime.is_(None), + Notification.endtime >= now + ) + ) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + Notification.notification.ilike(f'%{search}%') + ) + + # Sorting by start time (newest first) + query = query.order_by(Notification.starttime.desc()) + + items, total = paginate_query(query, page, per_page) + data = [n.to_dict() for n in items] + + return paginated_response(data, page, per_page, total) + + +@notifications_bp.route('/', methods=['GET']) +def get_notification(notification_id: int): + """Get a single notification.""" + n = db.session.get(Notification, notification_id) + + if not n: + return error_response( + ErrorCodes.NOT_FOUND, + f'Notification with ID {notification_id} not found', + http_code=404 + ) + + return success_response(n.to_dict()) + + +@notifications_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('notifications.create') +def create_notification(): + """Create a new notification.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Validate required fields + notification_text = data.get('notification') or data.get('message') + if not notification_text: + return error_response(ErrorCodes.VALIDATION_ERROR, 'notification/message is required') + + # Parse dates + starttime = datetime.now(timezone.utc).replace(tzinfo=None) + if data.get('starttime') or data.get('startdate'): + try: + date_str = data.get('starttime') or data.get('startdate') + starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except ValueError: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format') + + endtime = None + if data.get('endtime') or data.get('enddate'): + try: + date_str = data.get('endtime') or data.get('enddate') + endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except ValueError: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format') + + # No explicit end time: apply the per-type display window (recognition + # clears at the next 8 AM Eastern, recertification runs two weeks). + if endtime is None and data.get('notificationtypeid'): + ntype = db.session.get(NotificationType, data['notificationtypeid']) + if ntype: + endtime = _auto_endtime(ntype, starttime) + + n = Notification( + notification=notification_text, + notificationtypeid=data.get('notificationtypeid'), + businessunitid=data.get('businessunitid'), + appid=data.get('appid'), + starttime=starttime, + endtime=endtime, + ticketnumber=data.get('ticketnumber'), + link=data.get('link') or data.get('linkurl'), + isactive=True, + isshopfloor=data.get('isshopfloor', False), + employeesso=data.get('employeesso'), + employeename=data.get('employeename') + ) + + db.session.add(n) + db.session.commit() + + return success_response(n.to_dict(), message='Notification created', http_code=201) + + +@notifications_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('notifications.edit') +def update_notification(notification_id: int): + """Update a notification.""" + n = db.session.get(Notification, notification_id) + + if not n: + return error_response( + ErrorCodes.NOT_FOUND, + f'Notification with ID {notification_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Update text content + if 'notification' in data or 'message' in data: + n.notification = data.get('notification') or data.get('message') + + # Update simple fields + if 'notificationtypeid' in data: + n.notificationtypeid = data['notificationtypeid'] + if 'businessunitid' in data: + n.businessunitid = data['businessunitid'] + if 'appid' in data: + n.appid = data['appid'] + if 'ticketnumber' in data: + n.ticketnumber = data['ticketnumber'] + if 'link' in data or 'linkurl' in data: + n.link = data.get('link') or data.get('linkurl') + if 'isactive' in data: + n.isactive = data['isactive'] + if 'isshopfloor' in data: + n.isshopfloor = data['isshopfloor'] + if 'employeesso' in data: + n.employeesso = data['employeesso'] + if 'employeename' in data: + n.employeename = data['employeename'] + + # Parse and update dates + if 'starttime' in data or 'startdate' in data: + date_str = data.get('starttime') or data.get('startdate') + if date_str: + try: + n.starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except ValueError: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format') + else: + n.starttime = datetime.now(timezone.utc).replace(tzinfo=None) + + if 'endtime' in data or 'enddate' in data: + date_str = data.get('endtime') or data.get('enddate') + if date_str: + try: + n.endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except ValueError: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format') + else: + n.endtime = None + + db.session.commit() + return success_response(n.to_dict(), message='Notification updated') + + +@notifications_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('notifications.delete') +def delete_notification(notification_id: int): + """Delete (soft delete) a notification.""" + n = db.session.get(Notification, notification_id) + + if not n: + return error_response( + ErrorCodes.NOT_FOUND, + f'Notification with ID {notification_id} not found', + http_code=404 + ) + + n.isactive = False + db.session.commit() + + return success_response(message='Notification deleted') + + +# ============================================================================= +# Special Endpoints +# ============================================================================= + +@notifications_bp.route('/active', methods=['GET']) +def get_active_notifications(): + """ + Get currently active notifications for display. + """ + now = datetime.now(timezone.utc).replace(tzinfo=None) + + from datetime import timedelta + lookahead = now + timedelta(days=10) + + notifications = Notification.query.filter( + Notification.isactive == True, + db.or_( + Notification.starttime.is_(None), + Notification.starttime <= lookahead + ), + db.or_( + Notification.endtime.is_(None), + Notification.endtime >= now + ) + ).order_by(Notification.starttime.asc()).all() + + data = [n.to_dict() for n in notifications] + + return success_response({ + 'notifications': data, + 'total': len(data) + }) + + +@notifications_bp.route('/calendar', methods=['GET']) +def get_calendar_events(): + """ + Get notifications in FullCalendar event format. + + Query parameters: + - start: Start date (ISO format) + - end: End date (ISO format) + """ + query = Notification.query.filter(Notification.isactive == True) + + # Date range filter + if start := request.args.get('start'): + try: + start_date = datetime.fromisoformat(start.replace('Z', '+00:00')) + query = query.filter( + db.or_( + Notification.endtime >= start_date, + Notification.endtime.is_(None) + ) + ) + except ValueError: + pass + + if end := request.args.get('end'): + try: + end_date = datetime.fromisoformat(end.replace('Z', '+00:00')) + query = query.filter(Notification.starttime <= end_date) + except ValueError: + pass + + notifications = query.order_by(Notification.starttime).all() + + events = [n.to_calendar_event() for n in notifications] + + return success_response(events) + + +@notifications_bp.route('/dashboard/summary', methods=['GET']) +def dashboard_summary(): + """Get notifications dashboard summary.""" + now = datetime.now(timezone.utc).replace(tzinfo=None) + + # Total active notifications + total_active = Notification.query.filter( + Notification.isactive == True, + db.or_( + Notification.starttime.is_(None), + Notification.starttime <= now + ), + db.or_( + Notification.endtime.is_(None), + Notification.endtime >= now + ) + ).count() + + # By type + by_type = db.session.query( + NotificationType.typename, + NotificationType.typecolor, + db.func.count(Notification.notificationid) + ).join(Notification + ).filter( + Notification.isactive == True + ).group_by(NotificationType.typename, NotificationType.typecolor + ).all() + + return success_response({ + 'active': total_active, + 'bytype': [{'type': t, 'color': c, 'count': n} for t, c, n in by_type] + }) + + +@notifications_bp.route('/employee/', methods=['GET']) +def get_employee_recognitions(sso): + """ + Get recognitions for a specific employee by SSO. + + Returns all recognition-type notifications where the employee is mentioned. + """ + if not sso or not sso.isdigit(): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Valid SSO required') + + # Find recognition type(s) + recognition_types = NotificationType.query.filter( + db.or_( + NotificationType.typecolor == 'recognition', + NotificationType.typename.ilike('%recognition%') + ) + ).all() + + recognition_type_ids = [rt.notificationtypeid for rt in recognition_types] + + # Find notifications where this employee is mentioned + # Check both exact match and comma-separated list + query = Notification.query.filter( + Notification.isactive == True, + db.or_( + Notification.employeesso == sso, + Notification.employeesso.like(f'{sso},%'), + Notification.employeesso.like(f'%,{sso}'), + Notification.employeesso.like(f'%,{sso},%') + ) + ) + + # Optionally filter to recognition types only + if recognition_type_ids: + query = query.filter(Notification.notificationtypeid.in_(recognition_type_ids)) + + query = query.order_by(Notification.starttime.desc()) + + notifications = query.all() + data = [n.to_dict() for n in notifications] + + return success_response({ + 'recognitions': data, + 'total': len(data) + }) + + +@notifications_bp.route('/shopfloor', methods=['GET']) +def get_shopfloor_notifications(): + """ + Get notifications for shopfloor TV dashboard. + + Returns current and upcoming notifications with isshopfloor=1. + Splits multi-employee recognition and training into separate entries. + + Query parameters: + - businessunit: Filter by business unit ID (null = all units) + """ + from datetime import timedelta + + now = datetime.now(timezone.utc).replace(tzinfo=None) + business_unit = request.args.get('businessunit') + + # Base query for shopfloor notifications + base_query = Notification.query.filter(Notification.isshopfloor == True) + + # Business unit filter + if business_unit and business_unit.isdigit(): + # Specific BU: show that BU's notifications AND null (all units) + base_query = base_query.filter( + db.or_( + Notification.businessunitid == int(business_unit), + Notification.businessunitid.is_(None) + ) + ) + else: + # All units: only show notifications with NULL businessunitid + base_query = base_query.filter(Notification.businessunitid.is_(None)) + + # Current notifications (active now or ended within 30 minutes) + thirty_min_ago = now - timedelta(minutes=30) + current_query = base_query.filter( + db.or_( + # Active and currently showing + db.and_( + Notification.isactive == True, + db.or_(Notification.starttime.is_(None), Notification.starttime <= now), + db.or_(Notification.endtime.is_(None), Notification.endtime >= now) + ), + # Recently ended (within 30 min) - show as resolved + db.and_( + Notification.endtime.isnot(None), + Notification.endtime >= thirty_min_ago, + Notification.endtime < now + ) + ) + ).order_by(Notification.notificationid.desc()) + + current_notifications = current_query.all() + + # Upcoming notifications (starts within next 5 days) + five_days = now + timedelta(days=5) + upcoming_query = base_query.filter( + Notification.isactive == True, + Notification.starttime > now, + Notification.starttime <= five_days + ).order_by(Notification.starttime) + + upcoming_notifications = upcoming_query.all() + + def notification_to_shopfloor(n, employee_override=None): + """Convert notification to shopfloor format.""" + is_resolved = n.endtime and n.endtime < now + ntype = n.notificationtype + show_photo = bool(ntype and ntype.showemployeephoto) + + result = { + 'notificationid': n.notificationid, + 'notification': n.notification, + 'starttime': n.starttime.isoformat() if n.starttime else None, + 'endtime': n.endtime.isoformat() if n.endtime else None, + 'ticketnumber': n.ticketnumber, + 'link': n.link, + 'isactive': n.isactive, + 'isshopfloor': True, + 'resolved': is_resolved, + 'typename': ntype.typename if ntype else None, + 'typecolor': ntype.typecolor if ntype else None, + # Per-type display behavior the dashboard groups/renders by. + 'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard', + } + + # Employee info (photo only when the type wants it) + if employee_override: + result['employeesso'] = employee_override.get('sso') + result['employeename'] = employee_override.get('name') + result['employeepicture'] = employee_override.get('picture') + else: + result['employeesso'] = n.employeesso + result['employeename'] = n.employeename + result['employeepicture'] = _employee_picture(n.employeesso) if show_photo else None + + return result + + def expand(n): + """One shopfloor card per notification, or one per employee when the + type is configured to split multi-employee lists.""" + ntype = n.notificationtype + is_split = bool(ntype and ntype.splitperemployee) + if not (is_split and n.employeesso and ',' in n.employeesso): + return [notification_to_shopfloor(n)] + + show_photo = bool(ntype and ntype.showemployeephoto) + ssos = [s.strip() for s in n.employeesso.split(',')] + names = n.employeename.split(', ') if n.employeename else [] + return [ + notification_to_shopfloor(n, { + 'sso': sso, + 'name': names[i] if i < len(names) else sso, + 'picture': _employee_picture(sso) if show_photo else None, + }) + for i, sso in enumerate(ssos) + ] + + current_data = [card for n in current_notifications for card in expand(n)] + upcoming_data = [card for n in upcoming_notifications for card in expand(n)] + + return success_response({ + 'timestamp': now.isoformat(), + 'current': current_data, + 'upcoming': upcoming_data, + 'configversion': _config_version(), + }) diff --git a/plugins/notifications/manifest.json b/plugins/notifications/manifest.json new file mode 100644 index 00000000..889fcd78 --- /dev/null +++ b/plugins/notifications/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "notifications", + "version": "1.0.0", + "description": "Notifications and announcements management plugin", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/notifications", + "provides": { + "features": ["notifications", "announcements", "calendar_events"] + } +} diff --git a/plugins/notifications/migrations/env.py b/plugins/notifications/migrations/env.py new file mode 100644 index 00000000..71e63a68 --- /dev/null +++ b/plugins/notifications/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the notifications 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_notifications. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'notifications' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/notifications/migrations/script.py.mako b/plugins/notifications/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/notifications/migrations/script.py.mako @@ -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"} diff --git a/plugins/notifications/migrations/versions/0001_notifications_anchor.py b/plugins/notifications/migrations/versions/0001_notifications_anchor.py new file mode 100644 index 00000000..a0d85ec5 --- /dev/null +++ b/plugins/notifications/migrations/versions/0001_notifications_anchor.py @@ -0,0 +1,28 @@ +"""notifications 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_notifications. From this +anchor forward, new notifications 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 = 'notifications0001anchor' +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 diff --git a/plugins/notifications/migrations/versions/0002_index_businessunitid.py b/plugins/notifications/migrations/versions/0002_index_businessunitid.py new file mode 100644 index 00000000..b14d4268 --- /dev/null +++ b/plugins/notifications/migrations/versions/0002_index_businessunitid.py @@ -0,0 +1,42 @@ +"""Index notifications.businessunitid (shopfloor list route filters on it). + +businessunitid is a soft-ref column the shopfloor feed filters by; it had no +index. Add ix_notifications_businessunitid (matches the model's index=True). + +Idempotent; downgrade drops it. + +Revision ID: notifications0002buidx +Revises: notifications0001anchor +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'notifications0002buidx' +down_revision = 'notifications0001anchor' +branch_labels = None +depends_on = None + +_INDEX = 'ix_notifications_businessunitid' + + +def _index_names(insp, table): + return {i['name'] for i in insp.get_indexes(table)} + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'notifications' not in insp.get_table_names(): + return + if _INDEX not in _index_names(insp, 'notifications'): + op.create_index(_INDEX, 'notifications', ['businessunitid']) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'notifications' not in insp.get_table_names(): + return + if _INDEX in _index_names(insp, 'notifications'): + op.drop_index(_INDEX, table_name='notifications') diff --git a/plugins/notifications/models/__init__.py b/plugins/notifications/models/__init__.py new file mode 100644 index 00000000..f6514a97 --- /dev/null +++ b/plugins/notifications/models/__init__.py @@ -0,0 +1,5 @@ +"""Notifications plugin models.""" + +from .notification import Notification, NotificationType + +__all__ = ['Notification', 'NotificationType'] diff --git a/plugins/notifications/models/notification.py b/plugins/notifications/models/notification.py new file mode 100644 index 00000000..42616b7e --- /dev/null +++ b/plugins/notifications/models/notification.py @@ -0,0 +1,195 @@ +"""Notifications plugin models - adapted to existing database schema.""" + +from datetime import datetime, timezone +from shopdb.api import db + + +class NotificationType(db.Model): + """ + Notification type classification. + Matches existing notificationtypes table. + """ + __tablename__ = 'notificationtypes' + + notificationtypeid = db.Column(db.Integer, primary_key=True) + typename = db.Column(db.String(50), nullable=False) + typedescription = db.Column(db.Text) + typecolor = db.Column(db.String(20), default='#17a2b8') + isactive = db.Column(db.Boolean, default=True) + + # Auto-expiry rule: when a notification of this type has no explicit end time, + # how long it stays up on the shopfloor board. + # 'none' -> indefinite (never auto-expires) + # 'duration' -> starttime + expirydays days + # 'dailytime' -> next expiryhour:expiryminute Eastern (daily reset) + expirymode = db.Column(db.String(20), default='none') + expirydays = db.Column(db.Integer, nullable=True) + expiryhour = db.Column(db.SmallInteger, nullable=True) + expiryminute = db.Column(db.SmallInteger, nullable=True, default=0) + + # Shopfloor display behavior (data-driven; replaces hardcoded per-type logic). + # splitperemployee -> one card per listed employee SSO + # showemployeephoto -> resolve + show each employee's photo + name + # displaystyle -> 'standard' (rows) | 'carousel' (rotating photo card) + # | 'grid' (cycling row of tiles) | 'banner' + splitperemployee = db.Column(db.Boolean, default=False) + showemployeephoto = db.Column(db.Boolean, default=False) + displaystyle = db.Column(db.String(20), default='standard') + + def __repr__(self): + return f"" + + def to_dict(self): + return { + 'notificationtypeid': self.notificationtypeid, + 'typename': self.typename, + 'typedescription': self.typedescription, + 'typecolor': self.typecolor, + 'isactive': self.isactive, + 'expirymode': self.expirymode or 'none', + 'expirydays': self.expirydays, + 'expiryhour': self.expiryhour, + 'expiryminute': self.expiryminute if self.expiryminute is not None else 0, + 'splitperemployee': bool(self.splitperemployee), + 'showemployeephoto': bool(self.showemployeephoto), + 'displaystyle': self.displaystyle or 'standard' + } + + +class Notification(db.Model): + """ + Notification/announcement model. + Matches existing notifications table schema. + """ + __tablename__ = 'notifications' + + notificationid = db.Column(db.Integer, primary_key=True) + notificationtypeid = db.Column( + db.Integer, + db.ForeignKey('notificationtypes.notificationtypeid'), + nullable=True + ) + businessunitid = db.Column(db.Integer, nullable=True, index=True) + appid = db.Column(db.Integer, nullable=True) + notification = db.Column(db.Text, nullable=False, comment='The message content') + starttime = db.Column(db.DateTime, nullable=True) + endtime = db.Column(db.DateTime, nullable=True) + ticketnumber = db.Column(db.String(50), nullable=True) + link = db.Column(db.String(500), nullable=True) + isactive = db.Column(db.Boolean, default=True) + isshopfloor = db.Column(db.Boolean, default=False) + # TEXT (not VARCHAR): recognition/recertification notifications comma-join + # every employee's SSO/name into one field, which overflows 100 chars once + # ~11 people are listed. + employeesso = db.Column(db.Text, nullable=True) + employeename = db.Column(db.Text, nullable=True) + + # Relationships + notificationtype = db.relationship('NotificationType', backref='notifications') + + def __repr__(self): + return f"" + + @property + def is_current(self): + """Check if notification is currently active based on dates.""" + now = datetime.now(timezone.utc).replace(tzinfo=None) + if not self.isactive: + return False + if self.starttime and now < self.starttime: + return False + if self.endtime and now > self.endtime: + return False + return True + + @property + def title(self): + """Get title - first line or first 100 chars of notification.""" + if not self.notification: + return '' + lines = self.notification.split('\n') + return lines[0][:100] if lines else self.notification[:100] + + def to_dict(self): + """Convert to dictionary with related data.""" + result = { + 'notificationid': self.notificationid, + 'notificationtypeid': self.notificationtypeid, + 'businessunitid': self.businessunitid, + 'appid': self.appid, + 'notification': self.notification, + 'title': self.title, + 'message': self.notification, + 'starttime': self.starttime.isoformat() if self.starttime else None, + 'endtime': self.endtime.isoformat() if self.endtime else None, + 'startdate': self.starttime.isoformat() if self.starttime else None, + 'enddate': self.endtime.isoformat() if self.endtime else None, + 'ticketnumber': self.ticketnumber, + 'link': self.link, + 'linkurl': self.link, + 'isactive': bool(self.isactive) if self.isactive is not None else True, + 'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False, + 'employeesso': self.employeesso, + 'employeename': self.employeename, + 'iscurrent': self.is_current + } + + # Add type info + if self.notificationtype: + result['typename'] = self.notificationtype.typename + result['typecolor'] = self.notificationtype.typecolor + + return result + + def to_calendar_event(self): + """Convert to FullCalendar event format.""" + # Color is data-driven: types store a hex typecolor. Only the legacy + # Bootstrap color-name aliases still need translating; hex passes through. + color_aliases = { + 'success': '#04b962', + 'warning': '#ff8800', + 'danger': '#f5365c', + 'info': '#14abef', + 'primary': '#7934f3', + 'secondary': '#94614f', + } + + ntype = self.notificationtype + raw_color = ntype.typecolor if ntype else '#14abef' + color = color_aliases.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef') + show_photo = bool(ntype and getattr(ntype, 'showemployeephoto', False)) + + # Employee-photo types prefix the card with the person's name/SSO. A + # multi-person recognition/training lists many people, so summarize as + # "First Person +N" to keep the calendar title short (the detail popup + # still shows the full employeename). + title = self.title + if show_photo: + employee_display = self.employeename or self.employeesso + if employee_display: + people = [p.strip() for p in employee_display.split(',') if p.strip()] + if len(people) > 1: + employee_display = f"{people[0]} +{len(people) - 1}" + title = f"{employee_display}: {title}" + + return { + 'id': self.notificationid, + 'title': title, + 'start': self.starttime.isoformat() if self.starttime else None, + 'end': self.endtime.isoformat() if self.endtime else None, + 'allDay': True, + 'backgroundColor': color, + 'borderColor': color, + 'extendedProps': { + 'notificationid': self.notificationid, + 'message': self.notification, + 'typename': ntype.typename if ntype else None, + 'typecolor': raw_color, + 'showemployeephoto': show_photo, + 'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard', + 'linkurl': self.link, + 'ticketnumber': self.ticketnumber, + 'employeename': self.employeename, + 'employeesso': self.employeesso, + } + } diff --git a/plugins/notifications/plugin.py b/plugins/notifications/plugin.py new file mode 100644 index 00000000..44dad50d --- /dev/null +++ b/plugins/notifications/plugin.py @@ -0,0 +1,231 @@ +"""Notifications plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint +import click + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db + +from .models import Notification, NotificationType +from .api import notifications_bp + +logger = logging.getLogger(__name__) + + +class NotificationsPlugin(BasePlugin): + """ + Notifications plugin - manages announcements and notifications. + + Provides functionality for: + - Creating and managing notifications/announcements + - Displaying banner notifications + - Calendar view of notifications + """ + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + 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 plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'notifications'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Notifications and announcements management' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/notifications'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return notifications_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [Notification, NotificationType] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Notifications plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensure_notification_types() + logger.info("Notifications plugin installed") + + def _ensure_notification_types(self) -> None: + """Ensure default notification types exist. + + For the special shopfloor types (recognition, training, + recertification) the typecolor is a keyword the dashboard maps to a + style and the feed uses to split multi-employee cards per employee; + generic types use a hex color. + """ + # (typename, typedescription, typecolor, expirymode, expirydays, + # expiryhour, splitperemployee, showemployeephoto, displaystyle) + default_types = [ + ('Awareness', 'General awareness notification', '#17a2b8', 'none', None, None, False, False, 'standard'), + ('Change', 'Planned change notification', '#ffc107', 'none', None, None, False, False, 'standard'), + ('Incident', 'Incident or outage notification', '#dc3545', 'none', None, None, False, False, 'standard'), + ('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'none', None, None, False, False, 'standard'), + ('General', 'General announcement', '#28a745', 'none', None, None, False, False, 'standard'), + ('Recognition', 'Employee recognition (clears at 8 AM Eastern)', '#ffc107', 'dailytime', None, 8, True, True, 'carousel'), + ('Training', 'Training notice (one card per employee)', '#17a2b8', 'none', None, None, True, True, 'carousel'), + ('Recertification', 'Employees due to retake a training course (shows two weeks)', '#0d6efd', 'duration', 14, None, True, True, 'grid'), + ] + + for (typename, typedescription, typecolor, expirymode, expirydays, + expiryhour, splitperemployee, showemployeephoto, displaystyle) in default_types: + existing = NotificationType.query.filter_by(typename=typename).first() + if not existing: + t = NotificationType( + typename=typename, + typedescription=typedescription, + typecolor=typecolor, + expirymode=expirymode, + expirydays=expirydays, + expiryhour=expiryhour, + expiryminute=0, + splitperemployee=splitperemployee, + showemployeephoto=showemployeephoto, + displaystyle=displaystyle + ) + db.session.add(t) + logger.debug(f"Created notification type: {typename}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Notifications plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('notifications') + def notifications_cli(): + """Notifications plugin commands.""" + pass + + @notifications_cli.command('list-types') + def list_types(): + """List all notification types.""" + from flask import current_app + + with current_app.app_context(): + types = NotificationType.query.filter_by(isactive=True).all() + if not types: + click.echo('No notification types found.') + return + + click.echo('Notification Types:') + for t in types: + click.echo(f" [{t.notificationtypeid}] {t.typename} ({t.color})") + + @notifications_cli.command('stats') + def stats(): + """Show notification statistics.""" + from flask import current_app + from datetime import datetime, timezone + + with current_app.app_context(): + now = datetime.now(timezone.utc).replace(tzinfo=None) + + total = Notification.query.filter( + Notification.isactive == True + ).count() + + active = Notification.query.filter( + Notification.isactive == True, + Notification.startdate <= now, + db.or_( + Notification.enddate.is_(None), + Notification.enddate >= now + ) + ).count() + + click.echo(f"Total notifications: {total}") + click.echo(f"Currently active: {active}") + + @notifications_cli.command('create') + @click.option('--title', required=True, help='Notification title') + @click.option('--message', required=True, help='Notification message') + @click.option('--type', 'type_name', default='General', help='Notification type') + def create_notification(title, message, type_name): + """Create a new notification.""" + from flask import current_app + + with current_app.app_context(): + ntype = NotificationType.query.filter_by(typename=type_name).first() + if not ntype: + click.echo(f"Error: Notification type '{type_name}' not found.") + return + + n = Notification( + title=title, + message=message, + notificationtypeid=ntype.notificationtypeid + ) + db.session.add(n) + db.session.commit() + + click.echo(f"Created notification #{n.notificationid}: {title}") + + return [notifications_cli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Active Notifications', + 'component': 'NotificationsWidget', + 'endpoint': '/api/notifications/dashboard/summary', + 'size': 'small', + 'position': 1, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Notifications', + 'icon': 'bell', + 'route': '/notifications', + 'position': 5, + }, + { + 'name': 'Calendar', + 'icon': 'calendar', + 'route': '/calendar', + 'position': 6, + }, + ] + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('notifications.view', 'View notifications', 'notifications'), + ('notifications.create', 'Create notifications', 'notifications'), + ('notifications.edit', 'Edit notifications', 'notifications'), + ('notifications.delete', 'Delete notifications', 'notifications'), + ] diff --git a/plugins/printers/__init__.py b/plugins/printers/__init__.py new file mode 100644 index 00000000..bc7220df --- /dev/null +++ b/plugins/printers/__init__.py @@ -0,0 +1,5 @@ +"""Printers plugin - extends machines with printer-specific functionality.""" + +from .plugin import PrintersPlugin + +__all__ = ['PrintersPlugin'] diff --git a/plugins/printers/api/__init__.py b/plugins/printers/api/__init__.py new file mode 100644 index 00000000..5226ee0e --- /dev/null +++ b/plugins/printers/api/__init__.py @@ -0,0 +1,7 @@ +"""Printers plugin API.""" + +from .asset_routes import printers_asset_bp # Asset-based API + +__all__ = [ + 'printers_asset_bp', +] diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py new file mode 100644 index 00000000..8a95e985 --- /dev/null +++ b/plugins/printers/api/asset_routes.py @@ -0,0 +1,1156 @@ +"""Printers API routes - new Asset-based architecture.""" + +import logging + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import db, cache, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query + +from ..models import Printer, PrinterType, ModelSupply, PrinterDriver +from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS +from ..services import ( + ZabbixService, + classifysupply, + derivesupplytype, + derivecolor, + lookupsupplies, +) + +logger = logging.getLogger(__name__) + +from shopdb.api import require_permission, apply_import_timestamps + +printers_asset_bp = Blueprint('printers_asset', __name__) + + +# ============================================================================= +# Printer Types +# ============================================================================= + +@printers_asset_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_printer_types(): + """List all printer types.""" + page, per_page = get_pagination_params(request) + + query = PrinterType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(PrinterType.isactive == True) + + if search := request.args.get('search'): + query = query.filter(PrinterType.printertype.ilike(f'%{search}%')) + + query = query.order_by(PrinterType.printertype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@printers_asset_bp.route('/types/', methods=['GET']) +@jwt_required(optional=True) +def get_printer_type(type_id: int): + """Get a single printer type.""" + t = db.session.get(PrinterType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@printers_asset_bp.route('/types', methods=['POST']) +@jwt_required() +@require_permission('printers.create') +def create_printer_type(): + """Create a new printer type.""" + data = request.get_json() + + if not data or not data.get('printertype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'printertype is required') + + existing = PrinterType.query.filter_by(printertype=data['printertype']).first() + if existing: + if not existing.isactive: + existing.isactive = True + for key in ('description', 'icon', 'color'): + if data.get(key) is not None: + setattr(existing, key, data[key]) + db.session.commit() + return success_response(existing.to_dict(), message='Reactivated existing type') + return error_response( + ErrorCodes.CONFLICT, + f"Printer type '{data['printertype']}' already exists", + http_code=409 + ) + + t = PrinterType( + printertype=data['printertype'], + description=data.get('description'), + icon=data.get('icon'), color=data.get('color') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Printer type created', http_code=201) + + +@printers_asset_bp.route('/types/', methods=['PUT']) +@jwt_required() +@require_permission('printers.edit') +def update_printer_type(type_id: int): + """Update a printer type.""" + t = db.session.get(PrinterType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, + f'Printer type with ID {type_id} not found', http_code=404) + + data = request.get_json() or {} + if 'printertype' in data and data['printertype'] != t.printertype: + if PrinterType.query.filter_by(printertype=data['printertype']).first(): + return error_response(ErrorCodes.CONFLICT, + f"Printer type '{data['printertype']}' already exists", http_code=409) + + for key in ['printertype', 'description', 'icon', 'color', 'isactive']: + if key in data: + setattr(t, key, data[key]) + + db.session.commit() + return success_response(t.to_dict(), message='Printer type updated') + + +@printers_asset_bp.route('/types/', methods=['DELETE']) +@jwt_required() +@require_permission('printers.delete') +def delete_printer_type(type_id: int): + """Delete a printer type. Refused if any printer still uses it.""" + t = db.session.get(PrinterType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Printer type not found', http_code=404) + inuse = Printer.query.filter_by(printertypeid=type_id).count() + if inuse: + return error_response(ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} printer(s) still use this type", http_code=409) + db.session.delete(t) + db.session.commit() + return success_response(message='Printer type deleted') + + +# ============================================================================= +# Printer Drivers (named SMB / HTTP links to driver packages) +# ============================================================================= + +@printers_asset_bp.route('/drivers', methods=['GET']) +@jwt_required(optional=True) +def list_drivers(): + """List printer drivers. ?active=false includes inactive ones.""" + query = PrinterDriver.query + if request.args.get('active', 'true').lower() != 'false': + query = query.filter_by(isactive=True) + drivers = query.order_by(PrinterDriver.name).all() + return success_response([d.to_dict() for d in drivers]) + + +@printers_asset_bp.route('/drivers', methods=['POST']) +@jwt_required() +@require_permission('printers.create') +def create_driver(): + data = request.get_json() or {} + if not (data.get('name') and data.get('location')): + return error_response(ErrorCodes.VALIDATION_ERROR, 'name and location are required') + d = PrinterDriver( + name=data['name'], + location=data['location'], + description=data.get('description'), + modelnumberid=data.get('modelnumberid') or None, + isactive=data.get('isactive', True), + ) + db.session.add(d) + db.session.commit() + return success_response(d.to_dict(), message='Driver created', http_code=201) + + +@printers_asset_bp.route('/drivers/', methods=['PUT']) +@jwt_required() +@require_permission('printers.edit') +def update_driver(driver_id): + d = db.session.get(PrinterDriver, driver_id) + if not d: + return error_response(ErrorCodes.NOT_FOUND, 'Driver not found', http_code=404) + data = request.get_json() or {} + for key in ('name', 'location', 'description', 'isactive'): + if key in data: + setattr(d, key, data[key]) + if 'modelnumberid' in data: + d.modelnumberid = data['modelnumberid'] or None + db.session.commit() + return success_response(d.to_dict(), message='Driver updated') + + +@printers_asset_bp.route('/drivers/', methods=['DELETE']) +@jwt_required() +@require_permission('printers.delete') +def delete_driver(driver_id): + d = db.session.get(PrinterDriver, driver_id) + if not d: + return error_response(ErrorCodes.NOT_FOUND, 'Driver not found', http_code=404) + db.session.delete(d) + db.session.commit() + return success_response(message='Driver deleted') + + +# ============================================================================= +# Printers CRUD +# ============================================================================= + +@printers_asset_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_printers(): + """ + List all printers with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status + - search: Search by asset number, name, or hostname + - type_id: Filter by printer type ID + - vendor_id: Filter by vendor ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + """ + page, per_page = get_pagination_params(request) + + # Join Printer with Asset + query = db.session.query(Printer).join(Asset) + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Exact-match natural-key lookup for idempotent import (asset number). + if exactassetnumber := request.args.get('assetnumber'): + query = query.filter(Asset.assetnumber == exactassetnumber) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%'), + Printer.hostname.ilike(f'%{search}%'), + Printer.windowsname.ilike(f'%{search}%') + ) + ) + + # Type filter + if typeid := request.args.get('typeid', request.args.get('type_id')): + query = query.filter(Printer.printertypeid == int(typeid)) + + # Vendor filter + if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')): + query = query.filter(Printer.vendorid == int(vendor_id)) + + # Location filter + if location_id := request.args.get('locationid', request.args.get('location_id')): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Sorting + sort_by = request.args.get('sort', 'hostname') + sort_dir = request.args.get('dir', 'asc') + + if sort_by == 'hostname': + col = Printer.hostname + elif sort_by == 'assetnumber': + col = Asset.assetnumber + elif sort_by == 'name': + col = Asset.name + else: + col = Printer.hostname + + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + + items, total = paginate_query(query, page, per_page) + + # Build response with both asset and printer data + data = [] + for printer in items: + item = printer.asset.to_dict() if printer.asset else {} + item['printer'] = printer.to_dict() + + # Add primary IP address + if printer.asset: + primary_comm = Communication.query.filter_by( + assetid=printer.asset.assetid, + isprimary=True + ).first() + if not primary_comm: + primary_comm = Communication.query.filter_by( + assetid=printer.asset.assetid + ).first() + item['ipaddress'] = primary_comm.ipaddress if primary_comm else None + + data.append(item) + + return paginated_response(data, page, per_page, total) + + +@printers_asset_bp.route('/install-list', methods=['GET']) +@jwt_required(optional=True) +def printer_install_list(): + """Flat, unpaginated list of network printers for the printer installer. + + Shopfloor 2.0 PCs cannot run unsigned .bat maps, so the signed installer EXE + pulls printer data + floor-map positions from here and renders the picker. + Replaces the classic apiprinters.asp contract, adding mapx/mapy. Network + printers only (USB-only printers are excluded). + """ + rows = [] + query = db.session.query(Printer).join(Asset).filter(Asset.isactive == True) + for printer in query.all(): + asset = printer.asset + if not asset: + continue + primary = Communication.query.filter_by( + assetid=asset.assetid, isprimary=True).first() \ + or Communication.query.filter_by(assetid=asset.assetid).first() + ipaddress = primary.ipaddress if primary else None + + # Network printers only: must have a hostname or a non-USB IP. + is_network = bool(printer.hostname) or (ipaddress and ipaddress != 'USB') + if not is_network: + continue + + data = printer.to_dict() + rows.append({ + 'printerid': printer.printerid, + 'name': asset.name or asset.assetnumber, + 'machinenumber': asset.assetnumber, + 'windowsname': printer.windowsname, + 'sharename': printer.sharename, + 'hostname': printer.hostname, + 'ipaddress': ipaddress, + 'vendorname': data.get('vendorname'), + 'modelnumber': data.get('modelname'), + 'installpath': printer.installpath, + 'iscsf': printer.iscsf, + 'locationname': asset.location.locationname if asset.location else None, + 'mapx': asset.mapx, + 'mapy': asset.mapy, + }) + + return success_response(rows) + + +@printers_asset_bp.route('/pc-default', methods=['GET']) +@jwt_required(optional=True) +def pc_default_printer(): + """Default printer for a PC, by machine (asset) number. + + Parity with classic apipcdefaultprinter.asp: the signed installer EXE + preselects a PC's default-printer hotspot on the site-map wizard using the + machine number persisted at PXE enrollment. The link is a `defaultprinter` + asset relationship (PC asset -> printer asset), so this stays inside the + contract surface (no cross-plugin model import). + + Returns {printerid, windowsname}, or {} when the machine is unknown or has + no active default printer set. + """ + machine = (request.args.get('machine') or '').strip() + if not machine: + return success_response({}) + + pc = Asset.query.filter_by(assetnumber=machine, isactive=True).first() + dp_type = RelationshipType.query.filter_by(relationshiptype='defaultprinter').first() + if not pc or not dp_type: + return success_response({}) + + rel = AssetRelationship.query.filter_by( + sourceassetid=pc.assetid, + relationshiptypeid=dp_type.relationshiptypeid, + isactive=True, + ).first() + if not rel: + return success_response({}) + + printer = db.session.query(Printer).join(Asset).filter( + Printer.assetid == rel.targetassetid, + Asset.isactive == True, + ).first() + if not printer: + return success_response({}) + + return success_response({ + 'printerid': printer.printerid, + 'windowsname': printer.windowsname, + }) + + +@printers_asset_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_printer(printer_id: int): + """Get a single printer with full details.""" + printer = db.session.get(Printer, printer_id) + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer with ID {printer_id} not found', + http_code=404 + ) + + result = printer.asset.to_dict() if printer.asset else {} + result['printer'] = printer.to_dict() + + # Add communications + if printer.asset: + comms = Communication.query.filter_by(assetid=printer.asset.assetid).all() + result['communications'] = [c.to_dict() for c in comms] + + # Attach active drivers that match this printer's model + if printer.modelnumberid: + drivers = PrinterDriver.query.filter_by( + modelnumberid=printer.modelnumberid, isactive=True + ).order_by(PrinterDriver.name).all() + result['drivers'] = [d.to_dict() for d in drivers] + else: + result['drivers'] = [] + + return success_response(result) + + +@printers_asset_bp.route('/by-asset/', methods=['GET']) +@jwt_required(optional=True) +def get_printer_by_asset(asset_id: int): + """Get printer data by asset ID.""" + printer = Printer.query.filter_by(assetid=asset_id).first() + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer for asset {asset_id} not found', + http_code=404 + ) + + result = printer.asset.to_dict() if printer.asset else {} + result['printer'] = printer.to_dict() + + return success_response(result) + + +@printers_asset_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('printers.create') +def create_printer(): + """ + Create new printer (creates both Asset and Printer records). + + Required fields: + - assetnumber: Business identifier + + Optional fields: + - name, serialnumber, statusid, locationid, businessunitid + - printertypeid, vendorid, modelnumberid, hostname + - windowsname, sharename, iscsf, installpath, pin + - iscolor, isduplex, isnetwork + - mapx, mapy, notes + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Get printer asset type + printer_type = AssetType.query.filter_by(assettype='printer').first() + if not printer_type: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Printer asset type not found. Plugin may not be properly installed.', + http_code=500 + ) + + # Create the core asset + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + serialnumber=data.get('serialnumber'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), + assettypeid=printer_type.assettypeid, + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapx=data.get('mapx'), + mapy=data.get('mapy'), + notes=data.get('notes') + ) + + db.session.add(asset) + db.session.flush() # Get the assetid + + # Create the printer extension + printer = Printer( + assetid=asset.assetid, + printertypeid=data.get('printertypeid'), + vendorid=data.get('vendorid'), + modelnumberid=data.get('modelnumberid'), + hostname=data.get('hostname'), + windowsname=data.get('windowsname'), + sharename=data.get('sharename'), + iscsf=data.get('iscsf', False), + installpath=data.get('installpath'), + pin=data.get('pin'), + iscolor=data.get('iscolor', False), + isduplex=data.get('isduplex', False), + isnetwork=data.get('isnetwork', True) + ) + + db.session.add(printer) + + # Create communication record if IP provided + if data.get('ipaddress'): + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + if ip_comtype: + comm = Communication( + assetid=asset.assetid, + comtypeid=ip_comtype.comtypeid, + ipaddress=data['ipaddress'], + isprimary=True + ) + db.session.add(comm) + + # Preserve legacy timestamps in import mode (no-op otherwise) + apply_import_timestamps(asset, data) + + db.session.commit() + + result = asset.to_dict() + result['printer'] = printer.to_dict() + + return success_response(result, message='Printer created', http_code=201) + + +@printers_asset_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('printers.edit') +def update_printer(printer_id: int): + """Update printer (both Asset and Printer records).""" + printer = db.session.get(Printer, printer_id) + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer with ID {printer_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + asset = printer.asset + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Update asset fields (optional identifiers gated per-type in Settings) + asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference', + 'maintenancereference', 'statusid', + 'locationid', 'businessunitid', 'mapx', 'mapy', + 'notes', 'isactive'] + for key in asset_fields: + if key in data: + setattr(asset, key, data[key]) + + # Update printer fields + printer_fields = ['printertypeid', 'vendorid', 'modelnumberid', 'hostname', + 'windowsname', 'sharename', 'iscsf', 'installpath', 'pin', + 'iscolor', 'isduplex', 'isnetwork'] + for key in printer_fields: + if key in data: + setattr(printer, key, data[key]) + + # Upsert the primary IP communication when an ipaddress is supplied, so a + # single PUT updates core, extension, and network in one call. + if 'ipaddress' in data: + ip = (data.get('ipaddress') or '').strip() + comm = Communication.query.filter_by( + assetid=asset.assetid, isprimary=True).first() + if ip: + if comm: + comm.ipaddress = ip + else: + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + if ip_comtype: + db.session.add(Communication( + assetid=asset.assetid, + comtypeid=ip_comtype.comtypeid, + ipaddress=ip, + isprimary=True, + )) + elif comm: + comm.ipaddress = None + + apply_import_timestamps(asset, data) + db.session.commit() + + result = asset.to_dict() + result['printer'] = printer.to_dict() + + return success_response(result, message='Printer updated') + + +@printers_asset_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('printers.delete') +def delete_printer(printer_id: int): + """Delete (soft delete) printer.""" + printer = db.session.get(Printer, printer_id) + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer with ID {printer_id} not found', + http_code=404 + ) + + # Soft delete the asset + printer.asset.isactive = False + db.session.commit() + + return success_response(message='Printer deleted') + + +# ============================================================================= +# Supply Levels (Zabbix Integration) +# ============================================================================= + +@printers_asset_bp.route('//supplies', methods=['GET']) +@jwt_required(optional=True) +def get_printer_supplies(printer_id: int): + """Get supply levels from Zabbix (real-time lookup).""" + printer = db.session.get(Printer, printer_id) + + if not printer: + return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) + + # Get IP address from communications + comm = Communication.query.filter_by( + assetid=printer.assetid, + isprimary=True + ).first() + if not comm: + comm = Communication.query.filter_by(assetid=printer.assetid).first() + + if not comm or not comm.ipaddress: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address') + + service = ZabbixService() + if not service.isconfigured or not service.isreachable: + # fail soft when zabbix off or down + return success_response({ + 'ipaddress': comm.ipaddress, + 'pingstatus': '-1', + 'supplies': [] + }) + + # vendor drives waste-cartridge rules; modelnumberid drives part lookup + vendor_name = printer.vendor.vendor if getattr(printer, 'vendor', None) else None + + raw_supplies = service.getsuppliesbyip(comm.ipaddress) or [] + supplies = [ + _annotate_supply(s, vendor_name, printer.modelnumberid) for s in raw_supplies + ] + + return success_response({ + 'ipaddress': comm.ipaddress, + 'pingstatus': service.getpingstatus(comm.ipaddress), + 'supplies': supplies + }) + + +# ============================================================================= +# Low Supplies +# ============================================================================= + +def _annotate_supply(supply, vendor_name, modelnumberid): + """Add status, remaining percent, and part numbers to a raw supply dict. + + Waste cartridge direction depends on vendor, so classification lives in + the supply_parts helper. Part numbers come from the modelsupplies table. + """ + level = supply.get('level', 0) + name = supply.get('name', 'Unknown') + supplytype = derivesupplytype(name) + color = derivecolor(name, supply.get('color')) + cls = classifysupply(level, name, vendor_name) + return { + 'name': name, + 'level': level, + 'color': color, + 'supplytype': supplytype, + 'status': cls['status'], + 'remaining': cls['remaining'], + 'iswaste': cls['iswaste'], + 'isdrum': cls['isdrum'], + 'partnumbers': lookupsupplies(modelnumberid, color, supplytype), + } + + +def _get_low_supplies_data(): + """Build low supplies data (cached for 5 minutes).""" + cached = cache.get('printers_low_supplies') + if cached is not None: + return cached + + service = ZabbixService() + if not service.isconfigured or not service.isreachable: + return {'printers': [], 'summary': {'total_checked': 0, 'low': 0, 'critical': 0}} + + # active printers with an IP, with vendor and model for waste/part rules + rows = ( + db.session.query(Printer, Asset, Communication, Vendor, Model) + .join(Asset, Asset.assetid == Printer.assetid) + .join(Communication, Communication.assetid == Asset.assetid) + .outerjoin(Vendor, Vendor.vendorid == Printer.vendorid) + .outerjoin(Model, Model.modelnumberid == Printer.modelnumberid) + .filter(Asset.isactive == True) + .filter(Communication.ipaddress.isnot(None)) + .filter(Communication.ipaddress != '') + .all() + ) + + # dedupe by printer id (a printer may have several comms) + seen = set() + unique_printers = [] + for printer, asset, comm, vendor, model in rows: + if printer.printerid not in seen: + seen.add(printer.printerid) + unique_printers.append((printer, asset, comm, vendor, model)) + + results = [] + total_checked = 0 + + for printer, asset, comm, vendor, model in unique_printers: + supplies = service.getsuppliesbyip_cached(comm.ipaddress) + if supplies is None: + continue + + total_checked += 1 + + vendor_name = vendor.vendor if vendor else None + model_number = model.modelnumber if model else None + modelnumberid = model.modelnumberid if model else None + + annotated = [] + has_low = False + for s in supplies: + item = _annotate_supply(s, vendor_name, modelnumberid) + if item['status'] != 'ok': + has_low = True + annotated.append(item) + + if has_low: + # location name for the report row + location_name = None + if asset.locationid: + from shopdb.api import Location + loc = db.session.get(Location, asset.locationid) + if loc: + location_name = loc.locationname + + results.append({ + 'printerid': printer.printerid, + 'printername': asset.name or printer.hostname or '', + 'assetnumber': asset.assetnumber or '', + 'ipaddress': comm.ipaddress, + 'vendor': vendor_name, + 'model': model_number, + 'location': location_name, + 'supplies': annotated + }) + + low_count = 0 + critical_count = 0 + for p in results: + has_critical = any(s['status'] == 'critical' for s in p['supplies']) + has_low = any(s['status'] == 'low' for s in p['supplies']) + if has_critical: + critical_count += 1 + elif has_low: + low_count += 1 + + data = { + 'printers': results, + 'summary': { + 'total_checked': total_checked, + 'low': low_count, + 'critical': critical_count + } + } + + cache.set('printers_low_supplies', data, timeout=300) + return data + + +@printers_asset_bp.route('/lowsupplies', methods=['GET']) +@jwt_required(optional=True) +def low_supplies(): + """Get printers with low or critical supply levels.""" + data = _get_low_supplies_data() + return success_response(data) + + +@printers_asset_bp.route('/lookup', methods=['GET']) +@jwt_required(optional=True) +def printer_lookup(): + """Find a printer by IP or FQDN. Parity with the classic printerlookup.asp. + + Zabbix uses this to jump straight to a printer record. Query with + ?ip=x.x.x.x or ?fqdn=hostname; returns the matching printer id. + """ + ip = (request.args.get('ip') or '').strip() + fqdn = (request.args.get('fqdn') or '').strip() + lookup_value = ip or fqdn + + if not lookup_value: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Provide ip or fqdn' + ) + + # match the IP against any active printer communication + row = ( + db.session.query(Printer, Asset) + .join(Asset, Asset.assetid == Printer.assetid) + .join(Communication, Communication.assetid == Asset.assetid) + .filter(Asset.isactive == True) + .filter(Communication.ipaddress == lookup_value) + .first() + ) + + if not row: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer not found: {lookup_value}', + http_code=404 + ) + + printer, asset = row + return success_response({ + 'printerid': printer.printerid, + 'assetid': asset.assetid, + 'assetnumber': asset.assetnumber, + 'name': asset.name or printer.hostname, + }) + + +@printers_asset_bp.route('/supplies/refresh', methods=['POST']) +@jwt_required() +@require_permission('printers.create') +def refresh_supplies_cache(): + """Clear cached Zabbix supply data so the next read pulls fresh values. + + Backs the toner report Refresh button (parity with adminclearcache.asp + type=zabbix). + """ + ZabbixService().clearcache() + return success_response(message='Supply cache cleared') + + +# ============================================================================= +# Dashboard +# ============================================================================= + +@printers_asset_bp.route('/dashboard/summary', methods=['GET']) +@jwt_required(optional=True) +def dashboard_summary(): + """Get printer dashboard summary data.""" + # Total active printers + total = db.session.query(Printer).join(Asset).filter( + Asset.isactive == True + ).count() + + # Count by printer type + by_type = db.session.query( + PrinterType.printertype, + db.func.count(Printer.printerid) + ).join(Printer, Printer.printertypeid == PrinterType.printertypeid + ).join(Asset, Asset.assetid == Printer.assetid + ).filter(Asset.isactive == True + ).group_by(PrinterType.printertype + ).all() + + # Count by vendor + by_vendor = db.session.query( + Vendor.vendor, + db.func.count(Printer.printerid) + ).join(Printer, Printer.vendorid == Vendor.vendorid + ).join(Asset, Asset.assetid == Printer.assetid + ).filter(Asset.isactive == True + ).group_by(Vendor.vendor + ).all() + + # Get real low/critical supply counts (skip if Zabbix not reachable) + low_count = 0 + critical_count = 0 + service = ZabbixService() + if service.isconfigured and service.isreachable: + try: + supply_data = _get_low_supplies_data() + low_count = supply_data['summary']['low'] + critical_count = supply_data['summary']['critical'] + except Exception as e: + logger.warning(f"Could not fetch supply data for dashboard: {e}") + + return success_response({ + 'total': total, + 'totalprinters': total, + 'online': total, + 'lowsupplies': low_count, + 'criticalsupplies': critical_count, + 'bytype': [{'type': t, 'count': c} for t, c in by_type], + 'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor], + }) + + +# ============================================================================= +# Model Supplies (data-driven toner/drum/waste part numbers) +# ============================================================================= + +def _validate_supply_payload(data): + """Return an error message if the supply payload is invalid, else None.""" + if not data: + return 'No data provided' + if not data.get('partnumber'): + return 'partnumber is required' + supplytype = data.get('supplytype', 'toner') + if supplytype not in SUPPLY_TYPES: + return f"supplytype must be one of {', '.join(SUPPLY_TYPES)}" + color = data.get('color', 'none') + if color not in SUPPLY_COLORS: + return f"color must be one of {', '.join(SUPPLY_COLORS)}" + capacitytier = data.get('capacitytier', 'standard') + if capacitytier not in CAPACITY_TIERS: + return f"capacitytier must be one of {', '.join(CAPACITY_TIERS)}" + return None + + +@printers_asset_bp.route('/supplies/meta', methods=['GET']) +@jwt_required(optional=True) +def supplies_meta(): + """Allowed values for supply type, color, and capacity tier (for the UI).""" + return success_response({ + 'supplytypes': list(SUPPLY_TYPES), + 'colors': list(SUPPLY_COLORS), + 'capacitytiers': list(CAPACITY_TIERS), + }) + + +@printers_asset_bp.route('/models', methods=['GET']) +@jwt_required(optional=True) +def list_supply_models(): + """List models with a supply count, for the supply-management picker. + + Query parameters: + - search: filter by model number + - vendor_id: filter by vendor + - withsupplies: 'true' to only return models that already have supplies + """ + page, per_page = get_pagination_params(request) + + supplycount = db.func.count(ModelSupply.modelsupplyid).label('supplycount') + query = ( + db.session.query(Model, Vendor.vendor, supplycount) + .outerjoin(Vendor, Vendor.vendorid == Model.vendorid) + .outerjoin(ModelSupply, db.and_( + ModelSupply.modelnumberid == Model.modelnumberid, + ModelSupply.isactive == True, + )) + .group_by(Model.modelnumberid, Vendor.vendor) + ) + + # Toner/drum/waste only apply to printers, so restrict the picker to + # printer models: those attached to a printer asset, or those that already + # carry supply mappings. Keeps machine/controller models out of the list. + printer_model_ids = ( + db.session.query(Printer.modelnumberid) + .filter(Printer.modelnumberid.isnot(None)) + ) + supply_model_ids = db.session.query(ModelSupply.modelnumberid) + query = query.filter(db.or_( + Model.modelnumberid.in_(printer_model_ids), + Model.modelnumberid.in_(supply_model_ids), + )) + + if search := request.args.get('search'): + query = query.filter(Model.modelnumber.ilike(f'%{search}%')) + if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')): + query = query.filter(Model.vendorid == int(vendor_id)) + if request.args.get('withsupplies', '').lower() == 'true': + query = query.having(supplycount > 0) + + query = query.order_by(Model.modelnumber) + + total = query.count() + rows = query.limit(per_page).offset((page - 1) * per_page).all() + + data = [{ + 'modelnumberid': model.modelnumberid, + 'modelnumber': model.modelnumber, + 'vendor': vendor, + 'vendorid': model.vendorid, + 'supplycount': count, + } for model, vendor, count in rows] + + return paginated_response(data, page, per_page, total) + + +@printers_asset_bp.route('/models//supplies', methods=['GET']) +@jwt_required(optional=True) +def list_model_supplies(modelnumberid: int): + """List all supplies mapped to a model.""" + model = db.session.get(Model, modelnumberid) + if not model: + return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404) + + supplies = ( + ModelSupply.query + .filter_by(modelnumberid=modelnumberid, isactive=True) + .order_by(ModelSupply.supplytype, ModelSupply.color, ModelSupply.capacitytier) + .all() + ) + return success_response({ + 'modelnumberid': modelnumberid, + 'modelnumber': model.modelnumber, + 'supplies': [s.to_dict() for s in supplies], + }) + + +@printers_asset_bp.route('/models//supplies', methods=['POST']) +@jwt_required() +@require_permission('printers.create') +def create_model_supply(modelnumberid: int): + """Add a supply to a model.""" + model = db.session.get(Model, modelnumberid) + if not model: + return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404) + + data = request.get_json() + message = _validate_supply_payload(data) + if message: + return error_response(ErrorCodes.VALIDATION_ERROR, message) + + existing = ModelSupply.query.filter_by( + modelnumberid=modelnumberid, + partnumber=data['partnumber'], + ).first() + if existing: + return error_response( + ErrorCodes.CONFLICT, + f"Part number '{data['partnumber']}' already mapped to this model", + http_code=409, + ) + + supply = ModelSupply( + modelnumberid=modelnumberid, + supplytype=data.get('supplytype', 'toner'), + color=data.get('color', 'none'), + capacitytier=data.get('capacitytier', 'standard'), + partnumber=data['partnumber'], + marketingname=data.get('marketingname'), + pageyield=data.get('pageyield'), + notes=data.get('notes'), + ) + db.session.add(supply) + db.session.commit() + + return success_response(supply.to_dict(), message='Supply added', http_code=201) + + +@printers_asset_bp.route('/supplies/', methods=['PUT']) +@jwt_required() +@require_permission('printers.edit') +def update_model_supply(modelsupplyid: int): + """Update a model supply.""" + supply = db.session.get(ModelSupply, modelsupplyid) + if not supply: + return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # validate only the fields present + merged = { + 'partnumber': data.get('partnumber', supply.partnumber), + 'supplytype': data.get('supplytype', supply.supplytype), + 'color': data.get('color', supply.color), + 'capacitytier': data.get('capacitytier', supply.capacitytier), + } + message = _validate_supply_payload(merged) + if message: + return error_response(ErrorCodes.VALIDATION_ERROR, message) + + if 'partnumber' in data and data['partnumber'] != supply.partnumber: + clash = ModelSupply.query.filter_by( + modelnumberid=supply.modelnumberid, + partnumber=data['partnumber'], + ).first() + if clash: + return error_response( + ErrorCodes.CONFLICT, + f"Part number '{data['partnumber']}' already mapped to this model", + http_code=409, + ) + + for field in ('supplytype', 'color', 'capacitytier', 'partnumber', + 'marketingname', 'pageyield', 'notes'): + if field in data: + setattr(supply, field, data[field]) + + db.session.commit() + return success_response(supply.to_dict(), message='Supply updated') + + +@printers_asset_bp.route('/supplies/', methods=['DELETE']) +@jwt_required() +@require_permission('printers.delete') +def delete_model_supply(modelsupplyid: int): + """Delete a model supply.""" + supply = db.session.get(ModelSupply, modelsupplyid) + if not supply: + return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404) + + db.session.delete(supply) + db.session.commit() + return success_response(message='Supply deleted') diff --git a/plugins/printers/manifest.json b/plugins/printers/manifest.json new file mode 100644 index 00000000..bb043cca --- /dev/null +++ b/plugins/printers/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "printers", + "version": "1.0.0", + "description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/printers", + "provides": { + "machine_category": "Printer", + "features": [ + "printer_extensions", + "driver_management", + "supply_tracking", + "zabbix_integration", + "qr_codes" + ] + }, + "settings": { + "zabbix_url": "", + "zabbix_token": "", + "supply_alert_threshold": 10, + "default_driver_source": "internal" + } +} diff --git a/plugins/printers/migrations/env.py b/plugins/printers/migrations/env.py new file mode 100644 index 00000000..3b88cd49 --- /dev/null +++ b/plugins/printers/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the printers 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_printers. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'printers' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/printers/migrations/script.py.mako b/plugins/printers/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/printers/migrations/script.py.mako @@ -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"} diff --git a/plugins/printers/migrations/versions/0001_printers_anchor.py b/plugins/printers/migrations/versions/0001_printers_anchor.py new file mode 100644 index 00000000..75a12edc --- /dev/null +++ b/plugins/printers/migrations/versions/0001_printers_anchor.py @@ -0,0 +1,28 @@ +"""printers 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_printers. From this +anchor forward, new printers 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 = 'printers0001anchor' +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 diff --git a/plugins/printers/models/__init__.py b/plugins/printers/models/__init__.py new file mode 100644 index 00000000..0c64ef5b --- /dev/null +++ b/plugins/printers/models/__init__.py @@ -0,0 +1,20 @@ +"""Printers plugin models.""" + +from .printer import Printer, PrinterType # Asset-based models +from .printer_driver import PrinterDriver +from .model_supply import ( # data-driven model -> toner/drum/waste mapping + ModelSupply, + SUPPLY_TYPES, + SUPPLY_COLORS, + CAPACITY_TIERS, +) + +__all__ = [ + 'Printer', + 'PrinterType', + 'PrinterDriver', + 'ModelSupply', + 'SUPPLY_TYPES', + 'SUPPLY_COLORS', + 'CAPACITY_TIERS', +] diff --git a/plugins/printers/models/model_supply.py b/plugins/printers/models/model_supply.py new file mode 100644 index 00000000..05d7575c --- /dev/null +++ b/plugins/printers/models/model_supply.py @@ -0,0 +1,56 @@ +"""Model-to-supply mapping - data-driven toner/drum/waste part numbers. + +Replaces the old hardcoded part-number table. Each row maps one printer +model to one supply part (a toner of a given color and capacity tier, or a +drum/waste/maintenance item). Lets new models and their toners be added +through the API/UI without a code change. +""" + +from shopdb.api import db, BaseModel + + +# allowed values, surfaced to the UI via the /supplies/meta endpoint +SUPPLY_TYPES = ('toner', 'drum', 'waste', 'maintenance') +SUPPLY_COLORS = ('black', 'cyan', 'magenta', 'yellow', 'none') +CAPACITY_TIERS = ('standard', 'high', 'extrahigh', 'metered', 'dmo') + + +class ModelSupply(BaseModel): + """One supply part belonging to one printer model.""" + __tablename__ = 'modelsupplies' + + modelsupplyid = db.Column(db.Integer, primary_key=True) + + modelnumberid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=False, + ) + + # toner, drum, waste, maintenance + supplytype = db.Column(db.String(20), nullable=False, default='toner') + # black, cyan, magenta, yellow, or none (drum/waste have no color) + color = db.Column(db.String(20), nullable=False, default='none') + # standard, high, extrahigh, metered, dmo + capacitytier = db.Column(db.String(20), nullable=False, default='standard') + + partnumber = db.Column(db.String(50), nullable=False) + marketingname = db.Column(db.String(120)) + pageyield = db.Column(db.Integer, comment='Rated page yield at 5 percent coverage') + notes = db.Column(db.Text) + + model = db.relationship('Model', backref='supplies') + + # one part number per model, no duplicates + __table_args__ = ( + db.UniqueConstraint('modelnumberid', 'partnumber', name='uq_modelsupply_part'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + data = super().to_dict() + if self.model: + data['modelnumber'] = self.model.modelnumber + return data diff --git a/plugins/printers/models/printer.py b/plugins/printers/models/printer.py new file mode 100644 index 00000000..428a2f2b --- /dev/null +++ b/plugins/printers/models/printer.py @@ -0,0 +1,123 @@ +"""Printer plugin models - new Asset-based architecture.""" + +from shopdb.api import db, BaseModel + + +class PrinterType(BaseModel): + """ + Printer type classification. + + Examples: Laser, Inkjet, Label, MFP, Plotter, etc. + """ + __tablename__ = 'printertypes' + + printertypeid = db.Column(db.Integer, primary_key=True) + printertype = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + color = db.Column(db.String(20), comment='CSS color for UI/map markers') + + def __repr__(self): + return f"" + + +class Printer(BaseModel): + """ + Printer-specific extension data (new Asset architecture). + + Links to core Asset table via assetid. + Stores printer-specific fields like type, Windows name, share name, etc. + """ + __tablename__ = 'printers' + + printerid = db.Column(db.Integer, primary_key=True) + + # Link to core asset + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Printer classification + printertypeid = db.Column( + db.Integer, + db.ForeignKey('printertypes.printertypeid'), + nullable=True + ) + + # Vendor + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + modelnumberid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=True + ) + + # Network identity + hostname = db.Column( + db.String(100), + comment='Network hostname' + ) + + # Windows/Network naming + windowsname = db.Column( + db.String(255), + comment='Windows printer name (e.g., \\\\server\\printer)' + ) + sharename = db.Column( + db.String(100), + comment='CSF/share name' + ) + + # Installation + iscsf = db.Column(db.Boolean, default=False, comment='Is CSF printer') + installpath = db.Column(db.String(255), comment='Driver install path') + + # Printer PIN (for secure print) + pin = db.Column(db.String(20)) + + # Features + iscolor = db.Column(db.Boolean, default=False, comment='Color capable') + isduplex = db.Column(db.Boolean, default=False, comment='Duplex capable') + isnetwork = db.Column(db.Boolean, default=True, comment='Network connected') + + # Relationships + asset = db.relationship( + 'Asset', + backref=db.backref('printer', uselist=False, lazy='joined') + ) + printertype = db.relationship('PrinterType', backref='printers') + vendor = db.relationship('Vendor', backref='printer_items') + model = db.relationship('Model', backref='printer_items') + + __table_args__ = ( + db.Index('idx_printer_type', 'printertypeid'), + db.Index('idx_printer_hostname', 'hostname'), + db.Index('idx_printer_windowsname', 'windowsname'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.printertype: + result['printertypename'] = self.printertype.printertype + if self.vendor: + result['vendorname'] = self.vendor.vendor + if self.model: + result['modelname'] = self.model.modelnumber + if self.model.imageurl: + result['imageurl'] = self.model.imageurl + + return result diff --git a/plugins/printers/models/printer_driver.py b/plugins/printers/models/printer_driver.py new file mode 100644 index 00000000..acb4227e --- /dev/null +++ b/plugins/printers/models/printer_driver.py @@ -0,0 +1,33 @@ +"""Printer driver: a named link (SMB path or HTTP URL) to driver files.""" + +from shopdb.api import db + + +class PrinterDriver(db.Model): + __tablename__ = 'printerdrivers' + + driverid = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(150), nullable=False) + # SMB path (\\\\server\\share\\...) or HTTP URL to the driver package + location = db.Column(db.String(500), nullable=False) + description = db.Column(db.Text) + # Optional: attach a driver to a specific printer model + modelnumberid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=True + ) + isactive = db.Column(db.Boolean, default=True, nullable=False) + + model = db.relationship('Model') + + def to_dict(self): + return { + 'driverid': self.driverid, + 'name': self.name, + 'location': self.location, + 'description': self.description, + 'modelnumberid': self.modelnumberid, + 'modelname': self.model.modelnumber if self.model else None, + 'isactive': bool(self.isactive), + } diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py new file mode 100644 index 00000000..7198092a --- /dev/null +++ b/plugins/printers/plugin.py @@ -0,0 +1,241 @@ +"""Printers plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint +import click + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db, AssetType + +from .models import Printer, PrinterType, ModelSupply, PrinterDriver +from .api import printers_asset_bp +from .services import ZabbixService + +logger = logging.getLogger(__name__) + + +class PrintersPlugin(BasePlugin): + """ + Printers plugin - manages printer assets. + + Supports both legacy Machine-based architecture and new Asset-based architecture: + - Legacy: PrinterData table linked to machines + - New: Printer table linked to assets + + Features: + - PrinterType classification + - Windows/network naming + - Zabbix integration for real-time supply level lookups + """ + + def __init__(self): + self._manifest = self._load_manifest() + self._zabbixservice = None + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + manifestpath = Path(__file__).parent / 'manifest.json' + if manifestpath.exists(): + with open(manifestpath, 'r') as f: + return json.load(f) + return {} + + @property + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'printers'), + version=self._manifest.get('version', '2.0.0'), + description=self._manifest.get( + 'description', + 'Printer management with Zabbix integration' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/printers'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """ + Return Flask Blueprint with API routes. + + Returns the new Asset-based blueprint. + Legacy Machine-based blueprint is registered separately in init_app. + """ + return printers_asset_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [ + Printer, # Asset-based + PrinterType, # printer type classification + PrinterDriver, # driver links (SMB/HTTP) + ModelSupply, # model -> toner/drum/waste part numbers + ] + + def get_services(self) -> Dict[str, Type]: + """Return plugin services.""" + return { + 'zabbix': ZabbixService, + } + + @property + def zabbixservice(self) -> ZabbixService: + """Get Zabbix service instance.""" + if self._zabbixservice is None: + self._zabbixservice = ZabbixService() + return self._zabbixservice + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + app.config.setdefault('ZABBIX_URL', '') + app.config.setdefault('ZABBIX_TOKEN', '') + + logger.info(f"Printers plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensure_asset_type() + self._ensure_printer_types() + logger.info("Printers plugin installed") + + def _ensure_asset_type(self) -> None: + """Ensure printer asset type exists.""" + existing = AssetType.query.filter_by(assettype='printer').first() + if not existing: + at = AssetType( + assettype='printer', + pluginname='printers', + tablename='printers', + description='Printers (laser, inkjet, label, MFP, plotter)', + icon='printer' + ) + db.session.add(at) + logger.debug("Created asset type: printer") + db.session.commit() + + def _ensure_printer_types(self) -> None: + """Ensure basic printer types exist (new architecture).""" + printer_types = [ + ('Laser', 'Standard laser printer', 'printer'), + ('Inkjet', 'Inkjet printer', 'printer'), + ('Label', 'Label/barcode printer', 'barcode'), + ('Card', 'ID / card printer', 'id-card'), + ('MFP', 'Multifunction printer with scan/copy/fax', 'printer'), + ('Plotter', 'Large format plotter', 'drafting-compass'), + ('Thermal', 'Thermal printer', 'temperature-high'), + ('Dot Matrix', 'Dot matrix printer', 'th'), + ('Other', 'Other printer type', 'printer'), + ] + + for name, description, icon in printer_types: + existing = PrinterType.query.filter_by(printertype=name).first() + if not existing: + pt = PrinterType( + printertype=name, + description=description, + icon=icon + ) + db.session.add(pt) + logger.debug(f"Created printer type: {name}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Printers plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('printers') + def printerscli(): + """Printers plugin commands.""" + pass + + @printerscli.command('check-supplies') + @click.argument('ip') + def checksupplies(ip): + """Check supply levels for a printer by IP (via Zabbix).""" + from flask import current_app + + with current_app.app_context(): + service = ZabbixService() + + if not service.isconfigured: + click.echo('Error: Zabbix not configured. Set ZABBIX_URL and ZABBIX_TOKEN.') + return + + supplies = service.getsuppliesbyip(ip) + if not supplies: + click.echo(f'No supply data found for {ip}') + return + + click.echo(f'Supply levels for {ip}:') + for supply in supplies: + click.echo(f" {supply['name']}: {supply['level']}%") + + @printerscli.command('seed-supplies') + def seedsuppliescommand(): + """Seed corrected model->toner part numbers into modelsupplies.""" + from flask import current_app + from .services import seedsupplies + + with current_app.app_context(): + summary = seedsupplies() + click.echo( + f"Seeded supplies: {summary['suppliesadded']} added across " + f"{summary['modelstouched']} models." + ) + + return [printerscli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Printer Status', + 'component': 'PrinterStatusWidget', + 'endpoint': '/api/printers/dashboard/summary', + 'size': 'medium', + 'position': 10, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Printers', + 'icon': 'printer', + 'route': '/printers', + 'position': 20, + }, + ] + + def get_reports(self) -> List[Dict]: + """Return report card definitions for the Reports hub.""" + return [ + { + 'id': 'toner', + 'name': 'Toner Report', + 'description': 'Printers with low or critical toner/supply levels', + 'category': 'printers', + 'route': '/reports/toner', + }, + ] + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('printers.view', 'View printers', 'printers'), + ('printers.create', 'Create printers', 'printers'), + ('printers.edit', 'Edit printers', 'printers'), + ('printers.delete', 'Delete printers', 'printers'), + ] diff --git a/plugins/printers/schemas/__init__.py b/plugins/printers/schemas/__init__.py new file mode 100644 index 00000000..2d84390d --- /dev/null +++ b/plugins/printers/schemas/__init__.py @@ -0,0 +1 @@ +"""Printers plugin schemas (for future Marshmallow serialization).""" diff --git a/plugins/printers/services/__init__.py b/plugins/printers/services/__init__.py new file mode 100644 index 00000000..6a78fa9c --- /dev/null +++ b/plugins/printers/services/__init__.py @@ -0,0 +1,19 @@ +"""Printers plugin services.""" + +from .zabbix_service import ZabbixService +from .supply_parts import ( + classifysupply, + derivesupplytype, + derivecolor, + lookupsupplies, +) +from .seed_supplies import seedsupplies + +__all__ = [ + 'ZabbixService', + 'classifysupply', + 'derivesupplytype', + 'derivecolor', + 'lookupsupplies', + 'seedsupplies', +] diff --git a/plugins/printers/services/seed_supplies.py b/plugins/printers/services/seed_supplies.py new file mode 100644 index 00000000..f29c103b --- /dev/null +++ b/plugins/printers/services/seed_supplies.py @@ -0,0 +1,358 @@ +"""Seed data for model -> supply mappings. + +Corrected against official HP and Xerox sources (verification pass +2026-06-25). Replaces the old hardcoded supply_parts table, which had +roughly ten wrong part numbers, scrambled colors, and HP high-yield +cartridges mislabeled as metered. + +Each family matches printer models by substring (matchkeys), the same way +the classic ASP report did, then attaches its supplies. seedsupplies() +finds or creates the vendor and a canonical model row, then inserts any +missing supply rows. Re-running is safe: existing part numbers are skipped. + +Key facts encoded here: + - HP "X" = high yield, NOT metered. HP metered cartridges are contractual + "C-suffix" SKUs and exist only on Enterprise/Managed hardware, so the + Pro color families (M454/M479, M251/M252/M277, M254/M255) have none. + - Xerox uses distinct sold / metered / dmo part numbers per color. +""" + +import logging + +from shopdb.api import db, Vendor, Model + +from ..models import ModelSupply + +logger = logging.getLogger(__name__) + + +def _toner(color, tier, partnumber, marketingname, pageyield=None): + return { + 'supplytype': 'toner', 'color': color, 'capacitytier': tier, + 'partnumber': partnumber, 'marketingname': marketingname, + 'pageyield': pageyield, + } + + +def _part(supplytype, partnumber, marketingname, notes=None): + return { + 'supplytype': supplytype, 'color': 'none', 'capacitytier': 'standard', + 'partnumber': partnumber, 'marketingname': marketingname, + 'pageyield': None, 'notes': notes, + } + + +# Corrected supply catalog. Each entry: vendor, canonical model name, +# substring matchkeys, and the list of supplies. +SEED = [ + # ----- HP color, Pro (no metered variant exists) ----- + { + 'vendor': 'HP', 'canonical': 'HP Color LaserJet Pro M454 / M479', + 'matchkeys': ['M454', 'M479'], + 'supplies': [ + _toner('black', 'standard', 'W2020A', '414A Black', 2400), + _toner('black', 'high', 'W2020X', '414X Black', 7500), + _toner('cyan', 'standard', 'W2021A', '414A Cyan', 2100), + _toner('cyan', 'high', 'W2021X', '414X Cyan', 6000), + _toner('yellow', 'standard', 'W2022A', '414A Yellow', 2100), + _toner('yellow', 'high', 'W2022X', '414X Yellow', 6000), + _toner('magenta', 'standard', 'W2023A', '414A Magenta', 2100), + _toner('magenta', 'high', 'W2023X', '414X Magenta', 6000), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP Color LaserJet Pro M251 / M252 / M277', + 'matchkeys': ['M251', 'M252', 'M277', 'M274'], + 'supplies': [ + _toner('black', 'standard', 'CF400A', '201A Black', 1500), + _toner('black', 'high', 'CF400X', '201X Black', 2800), + _toner('cyan', 'standard', 'CF401A', '201A Cyan', 1400), + _toner('cyan', 'high', 'CF401X', '201X Cyan', 2500), + _toner('yellow', 'standard', 'CF402A', '201A Yellow', 1400), + _toner('yellow', 'high', 'CF402X', '201X Yellow', 2500), + _toner('magenta', 'standard', 'CF403A', '201A Magenta', 1400), + _toner('magenta', 'high', 'CF403X', '201X Magenta', 2500), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP Color LaserJet Pro M254 / M255', + 'matchkeys': ['M254', 'M255', 'M280', 'M281'], + 'supplies': [ + _toner('black', 'standard', 'CF500A', '202A Black', 1400), + _toner('black', 'high', 'CF500X', '202X Black', 3200), + _toner('cyan', 'standard', 'CF501A', '202A Cyan', 1300), + _toner('cyan', 'high', 'CF501X', '202X Cyan', 2500), + _toner('yellow', 'standard', 'CF502A', '202A Yellow', 1300), + _toner('yellow', 'high', 'CF502X', '202X Yellow', 2500), + _toner('magenta', 'standard', 'CF503A', '202A Magenta', 1300), + _toner('magenta', 'high', 'CF503X', '202X Magenta', 2500), + ], + }, + # ----- HP mono (X = high yield; XC/YC = contractual metered) ----- + { + 'vendor': 'HP', 'canonical': 'HP LaserJet M404 / M406 / M428 / M430', + 'matchkeys': ['M404', 'M406', 'M428', 'M430'], + 'supplies': [ + _toner('black', 'standard', 'CF258A', '58A Black', 3000), + _toner('black', 'high', 'CF258X', '58X Black', 10000), + _toner('black', 'metered', 'CF258XC', '58X Black (Contract)', 10000), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP LaserJet M607 / M608 / M609 / M631 / M632 / M633', + 'matchkeys': ['M607', 'M608', 'M609', 'M631', 'M632', 'M633'], + 'supplies': [ + _toner('black', 'standard', 'CF237A', '37A Black', 11000), + _toner('black', 'high', 'CF237X', '37X Black', 25000), + # 37Y extra-high does NOT fit the M607 + _toner('black', 'extrahigh', 'CF237Y', '37Y Black (not M607)', 41000), + _toner('black', 'metered', 'CF237YC', '37Y Black (Contract)', 41000), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP LaserJet M506 / M527 / M501', + 'matchkeys': ['M506', 'M527', 'M501'], + 'supplies': [ + _toner('black', 'standard', 'CF287A', '87A Black', 9000), + _toner('black', 'high', 'CF287X', '87X Black', 18000), + _toner('black', 'metered', 'CF287XC', '87X Black (Contract)', 18000), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP LaserJet M602 / M603 / M4555', + 'matchkeys': ['M602', 'M603', 'M4555'], + 'supplies': [ + _toner('black', 'standard', 'CE390A', '90A Black', 10000), + # 90X does NOT fit the M601 + _toner('black', 'high', 'CE390X', '90X Black (not M601)', 24000), + _toner('black', 'metered', 'CE390XC', '90X Black (Contract)', 24000), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP LaserJet P3015 / M521 / M525', + 'matchkeys': ['P3015', 'M521', 'M525'], + 'supplies': [ + _toner('black', 'standard', 'CE255A', '55A Black', 6000), + _toner('black', 'high', 'CE255X', '55X Black', 12500), + _toner('black', 'metered', 'CE255XC', '55X Black (Contract)', 12500), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP LaserJet 4250 / 4350', + 'matchkeys': ['4250', '4350'], + 'supplies': [ + _toner('black', 'standard', 'Q5942A', '42A Black', 10000), + _toner('black', 'high', 'Q5942X', '42X Black', 20000), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP LaserJet M610 / M611 / M612 / M634 / M635 / M636', + 'matchkeys': ['M610', 'M611', 'M612', 'M634', 'M635', 'M636'], + 'supplies': [ + _toner('black', 'standard', 'W1470A', '147A Black', 10500), + _toner('black', 'high', 'W1470X', '147X Black', 25200), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP LaserJet Pro 4001 / 4101 (148 series)', + 'matchkeys': ['4001', '4101', '4002', '4102'], + 'supplies': [ + _toner('black', 'high', 'W1480X', '148X Black', 9500), + _toner('black', 'metered', 'W1020XC', '148 Black (Contract)', 9500), + ], + }, + + # ----- Xerox VersaLink color ----- + { + 'vendor': 'Xerox', 'canonical': 'Xerox VersaLink C415', + 'matchkeys': ['C415'], + 'supplies': [ + _toner('black', 'standard', '006R04677', 'C415 Black', 2400), + _toner('cyan', 'standard', '006R04678', 'C415 Cyan', 2000), + _toner('magenta', 'standard', '006R04679', 'C415 Magenta', 2000), + _toner('yellow', 'standard', '006R04680', 'C415 Yellow', 2000), + _toner('black', 'high', '006R04685', 'C415 Black (High)', 10500), + _toner('cyan', 'high', '006R04686', 'C415 Cyan (High)', 7000), + _toner('magenta', 'high', '006R04687', 'C415 Magenta (High)', 7000), + _toner('yellow', 'high', '006R04688', 'C415 Yellow (High)', 7000), + _toner('black', 'metered', '006R04693', 'C415 Black (Metered)', 15000), + _toner('cyan', 'metered', '006R04694', 'C415 Cyan (Metered)', 10000), + _toner('magenta', 'metered', '006R04695', 'C415 Magenta (Metered)', 10000), + _toner('yellow', 'metered', '006R04696', 'C415 Yellow (Metered)', 10000), + _part('drum', '013R00701', 'C415 Drum / Imaging Unit'), + _part('waste', '008R13325', 'C415 Waste Cartridge'), + ], + }, + { + 'vendor': 'Xerox', 'canonical': 'Xerox VersaLink C405', + 'matchkeys': ['C405', 'C400'], + 'supplies': [ + _toner('black', 'standard', '106R03500', 'C405 Black', 2500), + _toner('yellow', 'standard', '106R03501', 'C405 Yellow', 2500), + _toner('cyan', 'standard', '106R03502', 'C405 Cyan', 2500), + _toner('magenta', 'standard', '106R03503', 'C405 Magenta', 2500), + _toner('black', 'high', '106R03512', 'C405 Black (High)', 5000), + _toner('black', 'extrahigh', '106R03524', 'C405 Black (Extra High)', 10500), + _part('drum', '108R01121', 'C400 / C405 Drum'), + _part('waste', '108R01124', 'C400 / C405 Waste Cartridge'), + ], + }, + { + 'vendor': 'Xerox', 'canonical': 'Xerox VersaLink C7100 / C7120 / C7125 / C7130', + 'matchkeys': ['C7100', 'C7120', 'C7125', 'C7130'], + 'supplies': [ + _toner('black', 'standard', '006R01824', 'C7125 Black (Sold)', 31300), + _toner('cyan', 'standard', '006R01825', 'C7125 Cyan (Sold)', 18500), + _toner('magenta', 'standard', '006R01826', 'C7125 Magenta (Sold)', 18500), + _toner('yellow', 'standard', '006R01827', 'C7125 Yellow (Sold)', 18500), + _toner('black', 'metered', '006R01820', 'C7125 Black (Metered)', 22500), + _toner('cyan', 'metered', '006R01821', 'C7125 Cyan (Metered)', 15500), + _toner('magenta', 'metered', '006R01822', 'C7125 Magenta (Metered)', 15500), + _toner('yellow', 'metered', '006R01823', 'C7125 Yellow (Metered)', 15500), + _part('drum', '013R00688', 'C7125 Drum'), + _part('waste', '115R00129', 'C7000 / C7100 Waste Cartridge'), + ], + }, + { + 'vendor': 'Xerox', 'canonical': 'Xerox VersaLink B7100 / B7125 / B7130 / B7135', + 'matchkeys': ['B7100', 'B7125', 'B7130', 'B7135'], + 'supplies': [ + _toner('black', 'standard', '006R01818', 'B7125 Black (Sold/High)', 34300), + _toner('black', 'metered', '006R01817', 'B7125 Black (Metered)', 34300), + _toner('black', 'dmo', '006R01819', 'B7125 Black (DMO)', 34300), + _part('drum', '013R00687', 'B7125 Drum'), + _part('waste', '115R00129', 'B7000 / B7100 Waste Cartridge'), + ], + }, + { + 'vendor': 'Xerox', 'canonical': 'Xerox VersaLink B400 / B405', + 'matchkeys': ['B400', 'B405'], + 'supplies': [ + _toner('black', 'standard', '106R03580', 'B405 Black', 5900), + _toner('black', 'high', '106R03582', 'B405 Black (High)', 13900), + _toner('black', 'extrahigh', '106R03584', 'B405 Black (Extra High)', 24600), + _toner('black', 'metered', '106R03586', 'B405 Black (Metered)', 24600), + _part('drum', '101R00554', 'B400 / B405 Drum'), + # note: B405 has no waste cartridge + ], + }, + + # ----- Xerox AltaLink color ----- + { + 'vendor': 'Xerox', 'canonical': 'Xerox AltaLink C8130 / C8135 / C8145 / C8155 / C8170', + 'matchkeys': ['C8130', 'C8135', 'C8145', 'C8155', 'C8170'], + 'supplies': [ + _toner('black', 'standard', '006R01746', 'C8135 Black (Sold)'), + _toner('cyan', 'standard', '006R01747', 'C8135 Cyan (Sold)'), + _toner('magenta', 'standard', '006R01748', 'C8135 Magenta (Sold)'), + _toner('yellow', 'standard', '006R01749', 'C8135 Yellow (Sold)'), + _toner('black', 'metered', '006R01742', 'C8135 Black (Metered)'), + _toner('cyan', 'metered', '006R01743', 'C8135 Cyan (Metered)'), + _toner('magenta', 'metered', '006R01744', 'C8135 Magenta (Metered)'), + _toner('yellow', 'metered', '006R01745', 'C8135 Yellow (Metered)'), + _part('drum', '013R00681', 'C8135 Drum'), + _part('waste', '008R08101', 'C8135 Waste Cartridge'), + ], + }, + { + 'vendor': 'Xerox', + 'canonical': 'Xerox AltaLink C8030 / C8035 / C8045 / C8055 / EC8036', + 'matchkeys': ['C8030', 'C8035', 'C8045', 'C8055', 'EC8036', 'EC8056', 'C8036'], + 'supplies': [ + _toner('black', 'standard', '006R01697', 'C8030 Black (Sold)'), + _toner('cyan', 'standard', '006R01698', 'C8030 Cyan (Sold)'), + _toner('magenta', 'standard', '006R01699', 'C8030 Magenta (Sold)'), + _toner('yellow', 'standard', '006R01700', 'C8030 Yellow (Sold)'), + _toner('black', 'metered', '006R01701', 'C8030 Black (Metered)'), + _toner('cyan', 'metered', '006R01702', 'C8030 Cyan (Metered)'), + _toner('magenta', 'metered', '006R01703', 'C8030 Magenta (Metered)'), + _toner('yellow', 'metered', '006R01704', 'C8030 Yellow (Metered)'), + # legacy WorkCentre 78xx cross-reference set, still compatible + _toner('black', 'high', '006R01509', 'WC7800 Black (legacy)'), + _toner('yellow', 'high', '006R01510', 'WC7800 Yellow (legacy)'), + _toner('magenta', 'high', '006R01511', 'WC7800 Magenta (legacy)'), + _toner('cyan', 'high', '006R01512', 'WC7800 Cyan (legacy)'), + _part('drum', '013R00662', 'C8030 / EC8036 Drum'), + _part('waste', '008R13061', 'C8030 / EC8036 Waste Cartridge'), + ], + }, +] + + +def _find_or_create_vendor(name): + vendor = Vendor.query.filter_by(vendor=name).first() + if not vendor: + vendor = Vendor(vendor=name) + db.session.add(vendor) + db.session.flush() + return vendor + + +def _matching_models(matchkeys, vendorid): + """Existing models whose modelnumber contains any matchkey (this vendor).""" + found = [] + for key in matchkeys: + rows = Model.query.filter( + Model.vendorid == vendorid, + Model.modelnumber.ilike(f'%{key}%'), + ).all() + for row in rows: + if row not in found: + found.append(row) + return found + + +def seedsupplies(): + """Seed corrected model->supply data. Idempotent. + + Returns a summary dict with counts. Attaches supplies to every existing + model that matches a family's keys; if a family matches no existing model, + creates a canonical model row so its toners are still available. + """ + models_touched = 0 + supplies_added = 0 + + for family in SEED: + vendor = _find_or_create_vendor(family['vendor']) + targets = _matching_models(family['matchkeys'], vendor.vendorid) + + if not targets: + # modeltypeid is a legacy Model column (nullable); printers are + # asset-based now and carry their type via PrinterType, not here. + model = Model( + modelnumber=family['canonical'], + vendorid=vendor.vendorid, + ) + db.session.add(model) + db.session.flush() + targets = [model] + + for model in targets: + models_touched += 1 + existing = { + supply.partnumber + for supply in ModelSupply.query.filter_by( + modelnumberid=model.modelnumberid + ).all() + } + for spec in family['supplies']: + if spec['partnumber'] in existing: + continue + db.session.add(ModelSupply( + modelnumberid=model.modelnumberid, + supplytype=spec['supplytype'], + color=spec['color'], + capacitytier=spec['capacitytier'], + partnumber=spec['partnumber'], + marketingname=spec.get('marketingname'), + pageyield=spec.get('pageyield'), + notes=spec.get('notes'), + )) + supplies_added += 1 + + db.session.commit() + logger.info( + "Seeded printer supplies: %d models touched, %d supplies added", + models_touched, supplies_added + ) + return {'modelstouched': models_touched, 'suppliesadded': supplies_added} diff --git a/plugins/printers/services/supply_parts.py b/plugins/printers/services/supply_parts.py new file mode 100644 index 00000000..178b109a --- /dev/null +++ b/plugins/printers/services/supply_parts.py @@ -0,0 +1,107 @@ +"""Printer supply classification and part-number lookup. + +Part numbers now live in the modelsupplies table (see seed_supplies.py), +managed through the API/UI. This module keeps the runtime logic that is not +per-model data: classifying a reported level into ok/low/critical (waste +cartridges invert), deriving supply type and color from a Zabbix item name, +and reading the matching part numbers out of the database. +""" + +from typing import Dict, List, Optional + + +# alert thresholds (percent remaining) +CRITICAL_THRESHOLD = 5 +LOW_THRESHOLD = 10 + + +def derivesupplytype(name: str) -> str: + """Map a Zabbix item name to a supply type.""" + lowername = (name or "").lower() + if "waste" in lowername: + return "waste" + if "drum" in lowername or "imaging" in lowername: + return "drum" + if "maintenance" in lowername or "fuser" in lowername: + return "maintenance" + return "toner" + + +def derivecolor(name: str, tagcolor: Optional[str] = None) -> str: + """Best-effort supply color from a Zabbix color tag, then the item name.""" + color = (tagcolor or "").lower() + if "black" in color: + return "black" + if color in ("cyan", "magenta", "yellow"): + return color + if color in ("grey", "gray"): + return "gray" + + lowername = (name or "").lower() + for candidate in ("cyan", "magenta", "yellow", "black"): + if candidate in lowername: + return candidate + return "none" + + +def classifysupply(level: float, name: str, vendor: Optional[str]) -> Dict: + """Classify one supply item into ok/low/critical. + + Waste cartridge fill is inverted vs a toner level: a full waste cartridge + is bad. Standard vendors report waste as percent FULL (high = bad). Xerox + EC/AltaLink series report waste as percent capacity REMAINING (low = bad), + same direction as toner. Normalise everything to percent remaining first. + """ + lowername = (name or "").lower() + iswaste = "waste" in lowername + isdrum = "drum" in lowername or "imaging" in lowername + isxerox = bool(vendor) and "xerox" in vendor.lower() + + if iswaste and not isxerox: + remaining = 100 - level + else: + remaining = level + + if remaining <= CRITICAL_THRESHOLD: + status = "critical" + elif remaining <= LOW_THRESHOLD: + status = "low" + else: + status = "ok" + + return { + "status": status, + "remaining": round(remaining, 1), + "iswaste": iswaste, + "isdrum": isdrum, + } + + +def lookupsupplies(modelnumberid: Optional[int], color: str, + supplytype: str) -> List[Dict]: + """Part-number options for a model + color + supply type, from the DB. + + Returns every matching capacity tier (standard / high / metered / ...) so + the report can show all reorder options, like the classic report did. + """ + if not modelnumberid: + return [] + + from ..models import ModelSupply + + query = ModelSupply.query.filter_by( + modelnumberid=modelnumberid, + supplytype=supplytype, + isactive=True, + ) + # toners are color-specific; drum/waste/maintenance are not + if supplytype == 'toner' and color and color != 'none': + query = query.filter_by(color=color) + + rows = query.order_by(ModelSupply.capacitytier).all() + return [{ + 'partnumber': row.partnumber, + 'marketingname': row.marketingname, + 'capacitytier': row.capacitytier, + 'pageyield': row.pageyield, + } for row in rows] diff --git a/plugins/printers/services/zabbix_service.py b/plugins/printers/services/zabbix_service.py new file mode 100644 index 00000000..ddd8e603 --- /dev/null +++ b/plugins/printers/services/zabbix_service.py @@ -0,0 +1,254 @@ +"""Zabbix service for real-time printer supply lookups. + +Ports the classic ASP shopdb Zabbix integration (includes/zabbix.asp and +includes/zabbix_all_supplies.asp) to Python. Key behaviours preserved from +the live integration: + + - Auth via an Authorization: Bearer header (Zabbix 6.0+ / 7.0). + The old payload "auth" field is rejected by Zabbix 7.0. + - Hosts are named by IP address, so a host is located with + host.get filter {host: [ip]}, not by interface address. + - Supply levels come from items tagged component=supplies AND type=level, + not from a key_ substring search. + - Each level item carries a color tag used for display and part lookup. + +Configuration (database Setting overrides env var): + ZABBIX_ENABLED: turn the integration on + ZABBIX_URL: base URL or full api_jsonrpc.php URL + ZABBIX_TOKEN: API token +""" + +import logging +from typing import Dict, List, Optional + +import requests +from flask import current_app + +from shopdb.api import cache + +logger = logging.getLogger(__name__) + + +class ZabbixService: + """Zabbix API client for printer supply and ping lookups.""" + + CACHE_TTL = 300 # 5 min, matches the classic Application cache + REACHABLE_CHECK_TTL = 60 + + # quick fail for the reachability probe + REACHABLE_TIMEOUT = 1.0 + # (connect, read) for real API calls; item.get is slow, give it room + API_TIMEOUT = (3.0, 5.0) + + # supply-level item tags, mirrors zabbix.asp GetPrinterTonerLevels + SUPPLY_TAGS = [ + {"tag": "component", "value": "supplies", "operator": 0}, + {"tag": "type", "value": "level", "operator": 0}, + ] + + def __init__(self): + self._url = None + self._token = None + + # -- configuration ------------------------------------------------------- + + @property + def isenabled(self) -> bool: + """Whether the integration is switched on.""" + from shopdb.api import Setting + db_enabled = Setting.get('zabbix_enabled') + if db_enabled is not None: + return bool(db_enabled) + return current_app.config.get('ZABBIX_ENABLED', False) + + @property + def isconfigured(self) -> bool: + """Enabled, and a URL plus token are present.""" + if not self.isenabled: + return False + from shopdb.api import Setting + self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL') + self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN') + return bool(self._url and self._token) + + @property + def endpoint(self) -> str: + """Full JSON-RPC endpoint. Accept a base URL or the full path.""" + url = (self._url or "").rstrip("/") + if url.endswith("api_jsonrpc.php"): + return url + return f"{url}/api_jsonrpc.php" + + @property + def isreachable(self) -> bool: + """Cheap connectivity probe, cached for 60s.""" + if not self.isconfigured: + return False + + cache_key = 'zabbix_reachable' + cached = cache.get(cache_key) + if cached is not None: + return cached + + try: + response = requests.get(self.endpoint, timeout=self.REACHABLE_TIMEOUT) + # any non-5xx answer means the web tier responded, so the server is + # up. Zabbix 7.0 returns 412 to a bare GET on api_jsonrpc.php (it + # wants a POST with json-rpc content type); that still counts. + reachable = response.status_code < 500 + except requests.RequestException: + reachable = False + + cache.set(cache_key, reachable, timeout=self.REACHABLE_CHECK_TTL) + logger.debug("Zabbix reachability: %s", reachable) + return reachable + + # -- low level call ------------------------------------------------------ + + def _apicall(self, method: str, params: Dict) -> Optional[object]: + """One JSON-RPC call. Returns the result, or None on any error.""" + if not self.isconfigured: + return None + + payload = { + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1, + } + headers = { + "Content-Type": "application/json-rpc", + "Authorization": f"Bearer {self._token}", + } + + try: + response = requests.post( + self.endpoint, + json=payload, + headers=headers, + timeout=self.API_TIMEOUT, + ) + response.raise_for_status() + data = response.json() + except (requests.RequestException, ValueError) as exc: + logger.error("Zabbix %s call failed: %s", method, exc) + return None + + if "error" in data: + logger.error("Zabbix %s error: %s", method, data["error"]) + return None + + return data.get("result") + + # -- host / item lookups ------------------------------------------------- + + def gethostidbyip(self, ip: str) -> Optional[str]: + """Host id for a printer. Hosts are named by IP in this Zabbix.""" + result = self._apicall("host.get", { + "output": ["hostid"], + "filter": {"host": [ip]}, + }) + if result: + return result[0].get("hostid") + return None + + def _extract_color(self, item: Dict) -> str: + """Pull and normalise the color tag, falling back to the item name.""" + color = "" + for tag in item.get("tags", []) or []: + if tag.get("tag") == "color": + color = (tag.get("value") or "").lower() + break + if "black" in color: + color = "black" + elif color in ("grey", "gray"): + color = "gray" + + if not color: + name = (item.get("name") or "").lower() + for candidate in ("cyan", "magenta", "yellow", "black"): + if candidate in name: + color = candidate + break + if not color and ("gray" in name or "grey" in name): + color = "gray" + return color + + def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]: + """Current supply levels for a printer, by IP. + + Returns a list of dicts {name, level, color, itemid, status, state}, + or None if the host is not in Zabbix. Drum/maintenance items are kept + (callers decide what to surface); only disabled (status=1) and + unsupported (state=1) items are dropped, matching the classic report. + """ + hostid = self.gethostidbyip(ip) + if not hostid: + logger.debug("No Zabbix host for IP %s", ip) + return None + + items = self._apicall("item.get", { + "output": ["itemid", "name", "lastvalue", "lastclock", + "units", "status", "state"], + "hostids": hostid, + "selectTags": "extend", + "evaltype": 0, # and + "tags": self.SUPPLY_TAGS, + "sortfield": "name", + "monitored": True, + }) + if not items: + return [] + + supplies = [] + for item in items: + # skip disabled or unsupported items + if str(item.get("status", "0")) != "0": + continue + if str(item.get("state", "0")) != "0": + continue + try: + level = int(float(item.get("lastvalue", 0))) + except (ValueError, TypeError): + level = 0 + supplies.append({ + "name": item.get("name", "Unknown"), + "level": level, + "color": self._extract_color(item), + "itemid": item.get("itemid"), + }) + return supplies + + def getpingstatus(self, ip: str) -> str: + """ICMP ping state for a printer: '1' up, '0' down, '-1' unknown.""" + hostid = self.gethostidbyip(ip) + if not hostid: + return "-1" + items = self._apicall("item.get", { + "output": ["lastvalue"], + "hostids": hostid, + "search": {"key_": "icmpping"}, + }) + if items: + return str(items[0].get("lastvalue", "-1")) + return "-1" + + # -- caching wrappers ---------------------------------------------------- + + def getsuppliesbyip_cached(self, ip: str) -> Optional[List[Dict]]: + """getsuppliesbyip with a 5-minute per-IP cache.""" + cache_key = f"zabbix_supplies_{ip}" + result = cache.get(cache_key) + if result is not None: + return result + result = self.getsuppliesbyip(ip) + if result is not None: + cache.set(cache_key, result, timeout=self.CACHE_TTL) + return result + + def clearcache(self, ip: str = None): + """Drop cached supply data for one IP, plus the low-supplies roll-up.""" + if ip: + cache.delete(f"zabbix_supplies_{ip}") + cache.delete("printers_low_supplies") + cache.delete("zabbix_reachable") diff --git a/plugins/slides/PLAN-slide-manager.md b/plugins/slides/PLAN-slide-manager.md new file mode 100644 index 00000000..43bbb46c --- /dev/null +++ b/plugins/slides/PLAN-slide-manager.md @@ -0,0 +1,132 @@ +# Plan: Slides plugin -> full slide manager (Flask) + +Port the classic-ASP slide manager (tv-dashboard/slidemanager.asp + apislides.asp) +into the shopdb-flask `slides` plugin. Two surfaces (lobby display, shopfloor +screensaver), upload / reorder / delete, consumed by the lobby TV dashboard and +the EventSaver screensaver over HTTP. + +Existing plugin is read-only (single-folder GET). This extends it to full CRUD + +per-surface + a management UI. + +## Decisions (locked for this plan) + +- SURFACES: fixed allowlist `lobby`, `shopfloor` (mirrors the ASP allowlist). + Not user-defined - keeps validation + paths simple. +- STORAGE: image FILES on disk, ORDER/metadata in the DB. + - Files: `instance/slides//` (outside static; served via a + plugin route so we control content-type + path-traversal, like slide.asp). + - Metadata: one table (order + seconds + surface). Files are the source of + truth for existence; DB rows that lost their file are ignored + pruned + (same self-healing as GetOrderList in the ASP lib). + - Rationale: native, simple, no BLOBs. BLOB-in-DB is the fallback only if the + deploy host cannot give Flask a writable volume. +- MIGRATION: add the table to the CORE alembic chain (ADR-004 / 7c04 - + single authoritative chain, no per-plugin chain). +- AUTH: admin/CRUD routes JWT-protected. The FEED + image-serve routes are + PUBLIC (the screensaver + lobby kiosks have no auth - matches apislides.asp). +- NAMING: locked convention v1 - lowercase concatenated table/columns; + Python/JS vars mirror column names exactly. + +## Data model (core migration) + +Table `tvslides` (new, added to core chain): + +| column | type | notes | +|--------------|--------------|-----------------------------------------| +| slideid | int PK | | +| surface | varchar(20) | 'lobby' | 'shopfloor' (indexed) | +| filename | varchar(255) | safe basename, unique per surface | +| sortorder | int | play order within surface | +| seconds | int | 0 = use default interval | +| uploadeddate | datetime | | + +Unique (surface, filename). Model lives in `plugins/slides/models/tvslide.py`, +imported via `shopdb.api` surface only (contract purity). Registered in the core +migration chain per ADR-004. + +## Backend - plugins/slides/ + +``` +plugins/slides/ + plugin.py # SlidesPlugin: get_blueprint, get_models, get_navigation_items, on_install + manifest.json # api_prefix /api/slides, provides slideshow + slidemanager + models/tvslide.py # TvSlide model + api/routes.py # blueprint slides_bp +``` + +Routes (prefix `/api/slides`): + +- PUBLIC (no auth) - consumed by screensaver + lobby: + - `GET /feed?surface=lobby|shopfloor` + -> flat shape the EventSaver .scr + lobby expect: + `{success, surface, basepath, interval, slides:[{filename, seconds}]}` + basepath -> the image route below. (Deliberately NOT the success_response + {data:{}} wrapper, so the .scr parser needs no change.) + - `GET /img//` -> serve the image (content-type + + path-traversal guard; send_file from instance/slides//). +- ADMIN (JWT) - consumed by the Vue manager: + - `POST //upload` -> request.files (native multipart), save + + create TvSlide rows at end of order. Skips non-images. Unique-renames. + - `POST //order` -> body {order:[filename,...]} rewrite sortorder, + preserve seconds. + - `POST //delete` -> body {files:[...]} delete file + row (multi). + - `PATCH //` -> {seconds} (kept server-side; UI hidden for now). + - `GET /` -> admin list (ordered, with slideid) for the UI. + +Ordering/natural-sort: order comes from `sortorder`; newly-uploaded files append +in natural (numeric-aware) filename order so Slide1..Slide11 land right (port the +ASP NatKey). + +## Frontend - core (no frontend plugin system) + +Vue pages live in core `frontend/src/` (per project note - plugins own backend, +Vue pages are core). Add: + +- `frontend/src/views/SlideManager.vue` + - Surface tabs (Lobby Display / Shopfloor Screensaver). + - Upload (file input -> POST /upload), thumbnail grid/table. + - Drag reorder via vuedraggable -> POST /order. + - Checkbox multi-select + Delete Selected -> POST /delete. + - Inherits AppLayout (sidebar + topbar + theme) automatically -> matches the + site with ZERO styling work (the whole reason to move off ASP). +- Router entry in `frontend/src/router` (e.g. /slides), guarded (admin). +- Nav: plugin `get_navigation_items()` returns the sidebar entry; the frontend + nav consumes plugin nav hooks (as knowledgebase/notifications do). +- `frontend/src/api/slidesApi.js` wrapper for the admin calls. + +## Consumers + +- Lobby: `TVDashboard.vue` already hits /api/slides; repoint at `/feed?surface=lobby`. +- Screensaver: change `EventSaver.ini` `url=` to + `https:///api/slides/feed?surface=shopfloor`, rehash the ini + + bump the manifest DetectionValue. The .scr HTTP mode is unchanged because + /feed returns the flat shape it already parses. + +## Tests (pytest, per plugin conventions) + +- test_plugins/test_slides.py: upload (fake file) creates rows + file; feed + returns ordered flat shape; order persists + preserves seconds; delete removes + file+row; surface allowlist rejects junk; path-traversal guard on /img; + natural sort on append; feed is public / admin routes require JWT. +- Guard test already enforces contract-only imports. + +## Phasing + +1. Model + core migration + manifest bump. +2. Backend routes (feed + img public; upload/order/delete/list admin) + tests. +3. Vue SlideManager.vue + router + nav + slidesApi.js. +4. Repoint TVDashboard.vue to /feed?surface=lobby. +5. Cut over screensaver: EventSaver.ini url -> Flask /feed, rehash + manifest. +6. Retire classic ASP tv-dashboard slide pages once Flask is live for this site. + +## Open decisions / gates + +- DEPLOY GATE: shopdb-flask must be deployed + reachable by the screensaver PCs + and lobby, with a writable `instance/slides` volume. Live shopdb is still the + classic ASP box; Flask prod target is docker, not yet deployed here. This plan + is buildable now but not cutover-able until Flask is live. +- Image serving: via a plugin route (send_file, guarded) vs Flask static. Route + chosen for the traversal guard + content-type control (parity with slide.asp). +- Seconds field: model + PATCH kept; UI control hidden for now (matches the ASP + decision to hide per-slide delay). +- BLOB fallback: only if the host denies a writable slides volume. diff --git a/plugins/slides/__init__.py b/plugins/slides/__init__.py new file mode 100644 index 00000000..86e87873 --- /dev/null +++ b/plugins/slides/__init__.py @@ -0,0 +1,5 @@ +"""Slides plugin package.""" + +from .plugin import SlidesPlugin + +__all__ = ['SlidesPlugin'] diff --git a/plugins/slides/api/__init__.py b/plugins/slides/api/__init__.py new file mode 100644 index 00000000..74cc3489 --- /dev/null +++ b/plugins/slides/api/__init__.py @@ -0,0 +1,5 @@ +"""Slides plugin API.""" + +from .routes import slides_bp + +__all__ = ['slides_bp'] diff --git a/plugins/slides/api/routes.py b/plugins/slides/api/routes.py new file mode 100644 index 00000000..d61ef70e --- /dev/null +++ b/plugins/slides/api/routes.py @@ -0,0 +1,210 @@ +"""Slides API: lobby-display + shopfloor-screensaver slideshows. + +Image files live on disk at instance/slides//; TvSlide rows hold play +order + per-slide seconds. Feed + image routes are PUBLIC (kiosks/screensaver +have no auth); management routes are admin-only. +""" + +import os +import re + +from flask import Blueprint, request, current_app, jsonify, send_from_directory +from flask_jwt_extended import jwt_required +from werkzeug.utils import secure_filename + +from shopdb.api import db, success_response, error_response, ErrorCodes, require_role + +from ..models import TvSlide + +slides_bp = Blueprint('slides', __name__) + +SURFACES = frozenset({'lobby', 'shopfloor'}) +VALID_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'} +DEFAULT_INTERVAL = 10 # seconds per slide when a slide's own seconds is 0 + + +def _surface_dir(surface): + return os.path.join(current_app.instance_path, 'slides', surface) + + +def _natkey(name): + """Natural sort key so Slide1..Slide11 order numerically, not lexically.""" + return [int(t) if t.isdigit() else t.lower() for t in re.split(r'(\d+)', name)] + + +def _valid_surface(surface): + return surface in SURFACES + + +def _is_image(filename): + return os.path.splitext(filename)[1].lower() in VALID_EXTENSIONS + + +# ============================================================================= +# PUBLIC - consumed by the lobby dashboard + the screensaver +# ============================================================================= + +@slides_bp.route('/feed', methods=['GET']) +def feed(): + """Flat playlist for a surface. NOT wrapped in the success_response envelope + so the screensaver's existing parser needs no change.""" + surface = request.args.get('surface', 'lobby') + if not _valid_surface(surface): + surface = 'lobby' + directory = _surface_dir(surface) + rows = (TvSlide.query.filter_by(surface=surface) + .order_by(TvSlide.sortorder, TvSlide.slideid).all()) + slides = [ + {'filename': r.filename, 'seconds': r.seconds or DEFAULT_INTERVAL} + for r in rows + if os.path.isfile(os.path.join(directory, r.filename)) + ] + return jsonify({ + 'success': True, + 'surface': surface, + 'basepath': f'/api/slides/img/{surface}/', + 'interval': DEFAULT_INTERVAL, + 'slides': slides, + }) + + +@slides_bp.route('/img//', methods=['GET']) +def serve_image(surface, filename): + """Serve a slide image with a path-traversal guard.""" + if not _valid_surface(surface): + return error_response(ErrorCodes.NOT_FOUND, 'Unknown surface', http_code=404) + safe = os.path.basename(filename) + if safe != filename or not safe: + return error_response(ErrorCodes.NOT_FOUND, 'Invalid filename', http_code=404) + directory = _surface_dir(surface) + if not os.path.isfile(os.path.join(directory, safe)): + return error_response(ErrorCodes.NOT_FOUND, 'Slide not found', http_code=404) + return send_from_directory(directory, safe) + + +# ============================================================================= +# ADMIN - the Vue slide manager +# ============================================================================= + +@slides_bp.route('/', methods=['GET']) +@jwt_required() +@require_role('admin') +def list_slides(surface): + if not _valid_surface(surface): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') + directory = _surface_dir(surface) + rows = (TvSlide.query.filter_by(surface=surface) + .order_by(TvSlide.sortorder, TvSlide.slideid).all()) + out = [] + for r in rows: + if os.path.isfile(os.path.join(directory, r.filename)): + item = r.to_dict() + item['url'] = f'/api/slides/img/{surface}/{r.filename}' + out.append(item) + return success_response(out) + + +@slides_bp.route('//upload', methods=['POST']) +@jwt_required() +@require_role('admin') +def upload_slides(surface): + if not _valid_surface(surface): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') + + files = request.files.getlist('files') + if not files and 'file' in request.files: + files = [request.files['file']] + if not files: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No files uploaded') + + directory = _surface_dir(surface) + os.makedirs(directory, exist_ok=True) + + # Append after the current max order; upload in natural filename order. + maxorder = db.session.query(db.func.max(TvSlide.sortorder)).filter_by(surface=surface).scalar() + order = (maxorder or 0) + existing = {r.filename for r in TvSlide.query.filter_by(surface=surface).all()} + + added = [] + for f in sorted(files, key=lambda x: _natkey(x.filename or '')): + if not f or not f.filename or not _is_image(f.filename): + continue + base = secure_filename(os.path.basename(f.filename)) + if not base: + continue + # Unique-rename if the name is taken (on disk or in db). + stem, ext = os.path.splitext(base) + name = base + n = 1 + while name in existing or os.path.exists(os.path.join(directory, name)): + name = f"{stem}_{n}{ext}" + n += 1 + f.save(os.path.join(directory, name)) + existing.add(name) + order += 1 + db.session.add(TvSlide(surface=surface, filename=name, sortorder=order, seconds=0)) + added.append(name) + + db.session.commit() + return success_response({'added': added}, message=f'{len(added)} slide(s) uploaded') + + +@slides_bp.route('//order', methods=['POST']) +@jwt_required() +@require_role('admin') +def reorder_slides(surface): + if not _valid_surface(surface): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') + data = request.get_json() or {} + order = data.get('order') or [] + for i, filename in enumerate(order): + row = TvSlide.query.filter_by(surface=surface, filename=filename).first() + if row: + row.sortorder = i + db.session.commit() + return success_response(message='Order saved') + + +@slides_bp.route('//delete', methods=['POST']) +@jwt_required() +@require_role('admin') +def delete_slides(surface): + if not _valid_surface(surface): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') + data = request.get_json() or {} + files = data.get('files') or [] + directory = _surface_dir(surface) + removed = 0 + for filename in files: + safe = os.path.basename(filename) + row = TvSlide.query.filter_by(surface=surface, filename=safe).first() + fpath = os.path.join(directory, safe) + if os.path.isfile(fpath): + try: + os.remove(fpath) + except OSError: + pass + if row: + db.session.delete(row) + removed += 1 + db.session.commit() + return success_response(message=f'{removed} slide(s) deleted') + + +@slides_bp.route('//', methods=['PATCH']) +@jwt_required() +@require_role('admin') +def update_slide(surface, slideid): + if not _valid_surface(surface): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') + row = db.session.get(TvSlide, slideid) + if not row or row.surface != surface: + return error_response(ErrorCodes.NOT_FOUND, 'Slide not found', http_code=404) + data = request.get_json() or {} + if 'seconds' in data: + try: + row.seconds = max(0, int(data['seconds'])) + except (TypeError, ValueError): + return error_response(ErrorCodes.VALIDATION_ERROR, 'seconds must be an integer') + db.session.commit() + return success_response(row.to_dict(), message='Slide updated') diff --git a/plugins/slides/manifest.json b/plugins/slides/manifest.json new file mode 100644 index 00000000..b5b8856a --- /dev/null +++ b/plugins/slides/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "slides", + "version": "2.0.0", + "description": "Slide manager for the lobby display and shopfloor screensaver (upload/reorder/delete per surface)", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.2.0,<1.0.0", + "api_prefix": "/api/slides", + "provides": { + "features": ["slideshow", "slidemanager"] + } +} diff --git a/plugins/slides/migrations/env.py b/plugins/slides/migrations/env.py new file mode 100644 index 00000000..7f02c385 --- /dev/null +++ b/plugins/slides/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the slides 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_slides. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'slides' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/slides/migrations/script.py.mako b/plugins/slides/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/slides/migrations/script.py.mako @@ -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"} diff --git a/plugins/slides/migrations/versions/0001_slides_anchor.py b/plugins/slides/migrations/versions/0001_slides_anchor.py new file mode 100644 index 00000000..bd2d9496 --- /dev/null +++ b/plugins/slides/migrations/versions/0001_slides_anchor.py @@ -0,0 +1,28 @@ +"""slides 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_slides. From this +anchor forward, new slides 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 = 'slides0001anchor' +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 diff --git a/plugins/slides/models/__init__.py b/plugins/slides/models/__init__.py new file mode 100644 index 00000000..5d433e15 --- /dev/null +++ b/plugins/slides/models/__init__.py @@ -0,0 +1,5 @@ +"""Slides plugin models.""" + +from .tvslide import TvSlide + +__all__ = ['TvSlide'] diff --git a/plugins/slides/models/tvslide.py b/plugins/slides/models/tvslide.py new file mode 100644 index 00000000..a5b5ac17 --- /dev/null +++ b/plugins/slides/models/tvslide.py @@ -0,0 +1,42 @@ +"""Slide model for the TV dashboard / screensaver slideshows.""" + +from datetime import datetime, timezone + +from shopdb.api import db + + +def _utcnow(): + # naive UTC to match the other DB DateTime columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class TvSlide(db.Model): + """One slide image in a surface's playlist. + + Image files live on disk at instance/slides//; this row + holds the play order + per-slide duration. Files are the source of truth for + existence - rows whose file is gone are ignored and pruned. + """ + __tablename__ = 'tvslides' + + slideid = db.Column(db.Integer, primary_key=True) + surface = db.Column(db.String(20), nullable=False, index=True) + filename = db.Column(db.String(255), nullable=False) + sortorder = db.Column(db.Integer, default=0, nullable=False) + seconds = db.Column(db.Integer, default=0, nullable=False) # 0 = use default interval + uploadeddate = db.Column(db.DateTime, default=_utcnow) + + # No DB-level unique on (surface, filename): utf8mb4 pushes that index past + # MySQL 5.6's 767-byte limit. Upload unique-renames, so dupes can't occur. + __table_args__ = ( + db.Index('idx_tvslide_surface', 'surface'), + ) + + def to_dict(self): + return { + 'slideid': self.slideid, + 'surface': self.surface, + 'filename': self.filename, + 'sortorder': self.sortorder, + 'seconds': self.seconds, + } diff --git a/plugins/slides/plugin.py b/plugins/slides/plugin.py new file mode 100644 index 00000000..41442f66 --- /dev/null +++ b/plugins/slides/plugin.py @@ -0,0 +1,74 @@ +"""Slides plugin main class. + +Display/service plugin: contributes a blueprint only (no model, no AssetType, +no nav). Serves TV-dashboard slideshow images. Demonstrates the minimal plugin +shape - a feature that is purely an API surface. +""" + +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 slides_bp +from .models import TvSlide + +logger = logging.getLogger(__name__) + + +class SlidesPlugin(BasePlugin): + """Slides plugin - TV dashboard slideshow images.""" + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + 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 plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'slides'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'TV dashboard slideshow images' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/slides'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return slides_bp + + def get_models(self) -> List[Type]: + """Slide playlist metadata (image files live on disk).""" + return [TvSlide] + + def get_navigation_items(self) -> List[Dict]: + """Sidebar entry for the slide manager (admin).""" + return [ + { + 'name': 'Slides', + 'icon': 'image', + 'route': '/settings/slides', + 'position': 7, + }, + ] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Slides plugin initialized (v{self.meta.version})") diff --git a/plugins/usb/README.md b/plugins/usb/README.md new file mode 100644 index 00000000..e1b30566 --- /dev/null +++ b/plugins/usb/README.md @@ -0,0 +1,105 @@ +# USB plugin - CMMC USB check-in/out database contract + +The USB plugin tracks removable-media check-in/out for CMMC compliance. Device +and log state live in a **separate, read-write** MySQL database (`cmmc_usb`), +reached with parameterized pymysql via `cmmc_usb_connection()`. Names of people +are resolved from the HR employee directory (see the employees plugin). + +The plugin's own reference tables (`usbdevicetypes`, `usbdevices`, +`usbcheckouts`) live in the main app database; only the live check-in/out data +is in `cmmc_usb`. + +> **The schema is standardized across sites** - the `cmmc_usb` check-in/out +> solution is the same deployment everywhere, so the tables below match as-is +> and no schema adaptation is needed. The **database name may differ per site**, +> though - set `cmmc_usb_db_name` (default `cmmc_usb`) to match the local name. +> The view recipe at the end is only a fallback for a site that somehow differs. +> (Contrast the employee directory, which genuinely varies per site.) + +## Connection + +Credentials resolve **settings-first, then environment**, except the password +which is env-only (never stored in the app database). + +| Field | Setting key (editable in the setup wizard) | Env var (fallback) | +| -------- | ------------------------------------------ | ----------------------- | +| Host | `cmmc_usb_db_host` | `CMMC_USB_DB_HOST` | +| Database | `cmmc_usb_db_name` | `CMMC_USB_DB_NAME` | +| User | `cmmc_usb_db_user` | `CMMC_USB_DB_USER` | +| Password | (not stored) | `CMMC_USB_DB_PASSWORD` | + +Set the password in `.env`; the setup wizard's Features step shows the exact +line to paste. This database is **read-write** - the app account needs +`SELECT, INSERT, UPDATE`. Engine is MySQL/MariaDB (pymysql). + +## Required schema + +The plugin runs raw SQL against these tables (or **views** - see below). + +### `devices` +| Column | Type | Notes | +| ----------------- | ------- | -------------------------------------- | +| `device_id` | VARCHAR | Primary key; the device serial/tag | +| `device_desc` | VARCHAR | Description | +| `device_owner` | VARCHAR | Owner | +| `status` | VARCHAR | Check-in/out state | +| `locker_location` | VARCHAR | Where the device is stored | + +Operations: `SELECT` (list + by id), `INSERT` (register device), `UPDATE` +(edit fields, change status). + +### `checkinoutlog` +| Column | Type | Notes | +| ----------------- | --------- | ---------------------------------- | +| `log_id` | INT (PK) | Auto id | +| `badge_number` | VARCHAR | Person's badge | +| `device_id` | VARCHAR | FK to `devices.device_id` | +| `action` | VARCHAR | check-in / check-out | +| `timestamp` | DATETIME | When it happened | +| `scanned_viruses` | (text/int)| Scan result | +| `locker_location` | VARCHAR | Locker at time of event | +| `sanitized` | (bool/int)| Sanitization flag | + +Operations: `SELECT` (history per device), `INSERT` (log an event). + +### `users` +| Column | Type | Notes | +| -------------- | ------- | ------------------------------------- | +| `badge_number` | VARCHAR | Primary key; the scanned badge | +| `first_name` | VARCHAR | Given name | +| `last_name` | VARCHAR | Surname | + +Operations: `SELECT` by badge, `INSERT` (auto-add a badge on first scan). + +## Employee directory dependency + +To turn a scanned badge into a name, the plugin also reads the HR `employees` +directory (via the employees plugin's connection). A badge shaped `0BZ` +carries a PayNo (the digits); lookups try `employees.SSO` and `employees.PayNo`. +See `plugins/employees/README.md` for that schema and connection. + +## Adapting a different site schema (recommended: views) + +Sites whose USB-tracking database uses different table/column names should +create read-only/updatable **views** named `devices`, `checkinoutlog`, and +`users` that map local columns to the names above. Example: + +```sql +CREATE VIEW devices AS +SELECT + asset_tag AS device_id, + description AS device_desc, + owner AS device_owner, + state AS status, + storage_bay AS locker_location +FROM usb_assets; +``` + +Because the plugin writes to `devices`/`checkinoutlog`/`users`, either make the +views updatable (single-table views usually are) or expose real tables with +these column names. Grant the app account `SELECT, INSERT, UPDATE`. + +Notes: +- `device_id` and `badge_number` are the natural keys the plugin matches on. +- If `cmmc_usb` is unreachable, USB endpoints return an error and the rest of + the app keeps working (the feature degrades, it does not crash the app). diff --git a/plugins/usb/__init__.py b/plugins/usb/__init__.py new file mode 100644 index 00000000..7fd2d87d --- /dev/null +++ b/plugins/usb/__init__.py @@ -0,0 +1,5 @@ +"""USB device checkout plugin.""" + +from .plugin import USBPlugin + +__all__ = ['USBPlugin'] diff --git a/plugins/usb/api/__init__.py b/plugins/usb/api/__init__.py new file mode 100644 index 00000000..0bb291c9 --- /dev/null +++ b/plugins/usb/api/__init__.py @@ -0,0 +1,5 @@ +"""USB plugin API.""" + +from .routes import usb_bp + +__all__ = ['usb_bp'] diff --git a/plugins/usb/api/routes.py b/plugins/usb/api/routes.py new file mode 100644 index 00000000..b721b533 --- /dev/null +++ b/plugins/usb/api/routes.py @@ -0,0 +1,723 @@ +"""USB plugin API endpoints. + +Two modes, chosen by the usb_directory_mode setting: + - 'selfhosted' (default): the app-owned tables (usbdevices/usbcheckouts/ + usbdevicetypes) via SQLAlchemy - see selfhosted.py. Each route below + delegates when _usb_selfhosted() is true. + - 'external': a separate MySQL database (cmmc_usb) reached with raw + parameterized pymysql via cmmc_usb_connection(), for a site that already + runs the cmmc_usb solution. That is the raw-SQL code in this file. + +Both modes return the same response shape so the frontend is identical. + +cmmc_usb schema used here: + devices(device_id PK, device_desc, device_owner, status, locker_location) + status is one of 'checked-in' | 'checked-out' | 'retired' + users(badge_number PK, first_name, last_name) + checkinoutlog(log_id PK auto, badge_number, device_id, action, timestamp, + scanned_viruses, locker_location, sanitized) + action is one of 'check-in' | 'check-out'; timestamp is a + 'YYYY-MM-DD HH:MM:SS' string; scanned_viruses/sanitized are '0'|'1'|null. +""" + +import logging +import re +from datetime import datetime + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import ( + cmmc_usb_connection, + employee_connection, + success_response, + error_response, + paginated_response, + ErrorCodes, + get_pagination_params, + require_permission, +) +from shopdb.api import Setting + +from . import selfhosted + +logger = logging.getLogger(__name__) + +usb_bp = Blueprint('usb', __name__) + + +def _usb_selfhosted(): + """True when USB uses the app-owned tables, not the external cmmc_usb DB.""" + row = Setting.query.filter_by(key='usb_directory_mode').first() + return (row.value if row and row.value else 'external').lower() == 'selfhosted' + +# public status strings on the devices row +_STATUS_CHECKED_IN = 'checked-in' +_STATUS_CHECKED_OUT = 'checked-out' +_STATUS_RETIRED = 'retired' + +# map the ?status= query values to the stored devices.status strings +_STATUS_FILTER = { + 'available': _STATUS_CHECKED_IN, + 'checkedout': _STATUS_CHECKED_OUT, + 'retired': _STATUS_RETIRED, +} + +# columns pulled for a devices row +_DEVICE_COLS = 'device_id, device_desc, device_owner, status, locker_location' + +# columns pulled for a checkinoutlog row +_LOG_COLS = ( + 'log_id, badge_number, device_id, action, timestamp, ' + 'scanned_viruses, locker_location, sanitized' +) + +# a badge like 0123456BZ carries a PayNo (the digits) wrapped in 0...BZ +_PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE) + + +# ============================================================================= +# Module helpers +# ============================================================================= + +def _now(): + """Current local time as a 'YYYY-MM-DD HH:MM:SS' string for the log.""" + return datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + +def _flag(value): + """Coerce a truthy/falsy input to a '1'/'0' string, or None when unset.""" + if value is None: + return None + if isinstance(value, bool): + return '1' if value else '0' + text = str(value).strip().lower() + if text in ('1', 'true', 'yes', 'y'): + return '1' + if text in ('0', 'false', 'no', 'n', ''): + return '0' + return None + + +def _lookup_employee_name(badge): + """Look up "First Last" in the HR directory for a badge. Empty on any miss. + + A numeric badge is an SSO. A 0BZ badge carries a PayNo (the middle + digits). Any other shape has no HR mapping so we return "". + """ + badge = (badge or '').strip() + if not badge: + return '' + + try: + conn = employee_connection() + except Exception: + logger.exception('HR directory connection failed for badge %s', badge) + return '' + + try: + with conn.cursor() as ecur: + if badge.isdigit(): + ecur.execute( + 'SELECT First_Name, Last_Name FROM employees WHERE SSO = %s', + (badge,), + ) + else: + match = _PAYNO_BADGE.match(badge) + if not match: + return '' + ecur.execute( + 'SELECT First_Name, Last_Name FROM employees WHERE PayNo = %s', + (match.group(1),), + ) + emp = ecur.fetchone() + except Exception: + logger.exception('HR directory lookup failed for badge %s', badge) + return '' + finally: + conn.close() + + if not emp: + return '' + first = (emp.get('First_Name') or '').strip() + last = (emp.get('Last_Name') or '').strip() + return (first + ' ' + last).strip() + + +def _resolve_badge_name(cur, badge): + """Resolve a badge to "First Last". Try cmmc_usb users, then HR directory. + + cur is an open cmmc_usb cursor. Returns "" when the badge cannot be + resolved anywhere. + """ + badge = (badge or '').strip() if badge is not None else '' + if not badge: + return '' + + cur.execute( + 'SELECT first_name, last_name FROM users WHERE badge_number = %s', + (badge,), + ) + row = cur.fetchone() + if row: + first = (row.get('first_name') or '').strip() + last = (row.get('last_name') or '').strip() + name = (first + ' ' + last).strip() + if name: + return name + + return _lookup_employee_name(badge) + + +def _ensure_user(cur, badge, name): + """Insert a badge into cmmc_usb users if absent. Splits name to first/last.""" + badge = (badge or '').strip() if badge is not None else '' + if not badge: + return + + cur.execute('SELECT badge_number FROM users WHERE badge_number = %s', (badge,)) + if cur.fetchone(): + return + + first, last = '', '' + if name: + parts = name.split() + if parts: + first = parts[0] + last = ' '.join(parts[1:]) + + cur.execute( + 'INSERT INTO users (badge_number, first_name, last_name) VALUES (%s, %s, %s)', + (badge, first, last), + ) + + +def _fetch_device(cur, device_id): + """Return the devices row for device_id, or None.""" + cur.execute( + 'SELECT ' + _DEVICE_COLS + ' FROM devices WHERE device_id = %s', + (device_id,), + ) + return cur.fetchone() + + +def _device_to_dict(cur, row): + """Build the device response dict, resolving owner + current holder names.""" + device = { + 'device_id': row.get('device_id'), + 'device_desc': row.get('device_desc'), + 'device_owner': row.get('device_owner'), + 'owner_name': _resolve_badge_name(cur, row.get('device_owner')), + 'status': row.get('status'), + 'locker_location': row.get('locker_location'), + 'current_holder': None, + 'current_holder_name': None, + 'checkout_time': None, + } + + if row.get('status') == _STATUS_CHECKED_OUT: + cur.execute( + "SELECT badge_number, timestamp FROM checkinoutlog " + "WHERE device_id = %s AND action = 'check-out' " + "ORDER BY timestamp DESC, log_id DESC LIMIT 1", + (row.get('device_id'),), + ) + log = cur.fetchone() + if log: + device['current_holder'] = log.get('badge_number') + device['current_holder_name'] = _resolve_badge_name( + cur, log.get('badge_number') + ) + device['checkout_time'] = log.get('timestamp') + + return device + + +def _log_to_dict(cur, row): + """Build a checkinoutlog response dict with the resolved badge name.""" + result = { + 'log_id': row.get('log_id'), + 'badge_number': row.get('badge_number'), + 'badge_name': _resolve_badge_name(cur, row.get('badge_number')), + 'device_id': row.get('device_id'), + 'action': row.get('action'), + 'timestamp': row.get('timestamp'), + 'scanned_viruses': row.get('scanned_viruses'), + 'locker_location': row.get('locker_location'), + 'sanitized': row.get('sanitized'), + } + # /checkouts joins in the current device status; pass it through when present + if 'device_status' in row: + result['device_status'] = row.get('device_status') + return result + + +# ============================================================================= +# USB Devices +# ============================================================================= + +@usb_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_usb_devices(): + """List USB devices with checkout status. + + Query parameters: + - page, per_page: pagination + - status: available | checkedout | retired + - search: match on device_id or device_desc + """ + if _usb_selfhosted(): + return selfhosted.list_devices(request) + page, per_page = get_pagination_params(request) + + where = [] + params = [] + + status_arg = request.args.get('status', '').strip().lower() + if status_arg in _STATUS_FILTER: + where.append('status = %s') + params.append(_STATUS_FILTER[status_arg]) + + search = request.args.get('search', '').strip() + if search: + where.append('(device_id LIKE %s OR device_desc LIKE %s)') + like = '%' + search + '%' + params.extend([like, like]) + + where_sql = (' WHERE ' + ' AND '.join(where)) if where else '' + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + cur.execute('SELECT COUNT(*) AS total FROM devices' + where_sql, params) + total = cur.fetchone()['total'] + + cur.execute( + 'SELECT ' + _DEVICE_COLS + ' FROM devices' + where_sql + + ' ORDER BY device_id LIMIT %s OFFSET %s', + params + [per_page, (page - 1) * per_page], + ) + rows = cur.fetchall() + data = [_device_to_dict(cur, row) for row in rows] + finally: + conn.close() + + return paginated_response(data, page, per_page, total) + + +@usb_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_usb_device(device_id): + """Get a single device plus its last 20 check-in/out log rows.""" + if _usb_selfhosted(): + return selfhosted.get_device(device_id) + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + row = _fetch_device(cur, device_id) + if not row: + return error_response( + ErrorCodes.NOT_FOUND, + f'USB device {device_id} not found', + http_code=404, + ) + + result = _device_to_dict(cur, row) + + cur.execute( + 'SELECT ' + _LOG_COLS + ' FROM checkinoutlog WHERE device_id = %s ' + 'ORDER BY timestamp DESC, log_id DESC LIMIT 20', + (device_id,), + ) + logs = cur.fetchall() + result['checkinoutlog'] = [_log_to_dict(cur, log) for log in logs] + finally: + conn.close() + + return success_response(result) + + +@usb_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('usb.create') +def create_usb_device(): + """Add a device (starts checked-in).""" + if _usb_selfhosted(): + return selfhosted.create_device(request.get_json() or {}) + data = request.get_json() or {} + + device_id = (data.get('device_id') or '').strip() + if not device_id: + return error_response(ErrorCodes.VALIDATION_ERROR, 'device_id is required') + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + cur.execute( + 'SELECT device_id FROM devices WHERE device_id = %s', (device_id,) + ) + if cur.fetchone(): + return error_response( + ErrorCodes.CONFLICT, 'device_id already exists', http_code=409 + ) + + cur.execute( + 'INSERT INTO devices ' + '(device_id, device_desc, device_owner, status, locker_location) ' + 'VALUES (%s, %s, %s, %s, %s)', + ( + device_id, + data.get('device_desc'), + data.get('device_owner'), + _STATUS_CHECKED_IN, + data.get('locker_location'), + ), + ) + conn.commit() + + with conn.cursor() as cur: + row = _fetch_device(cur, device_id) + result = _device_to_dict(cur, row) + finally: + conn.close() + + return success_response(result, message='Device created', http_code=201) + + +@usb_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('usb.edit') +def update_usb_device(device_id): + """Edit device_desc / device_owner / locker_location / status.""" + if _usb_selfhosted(): + return selfhosted.update_device(device_id, request.get_json() or {}) + data = request.get_json() or {} + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + if not _fetch_device(cur, device_id): + return error_response( + ErrorCodes.NOT_FOUND, + f'USB device {device_id} not found', + http_code=404, + ) + + fields = [] + params = [] + for col in ('device_desc', 'device_owner', 'locker_location', 'status'): + if col in data: + fields.append(col + ' = %s') + params.append(data[col]) + + if fields: + params.append(device_id) + cur.execute( + 'UPDATE devices SET ' + ', '.join(fields) + ' WHERE device_id = %s', + params, + ) + conn.commit() + + with conn.cursor() as cur: + row = _fetch_device(cur, device_id) + result = _device_to_dict(cur, row) + finally: + conn.close() + + return success_response(result, message='Device updated') + + +@usb_bp.route('//retire', methods=['POST']) +@jwt_required() +@require_permission('usb.edit') +def retire_usb_device(device_id): + """Retire a device (status -> retired).""" + if _usb_selfhosted(): + return selfhosted.retire_device(device_id) + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + if not _fetch_device(cur, device_id): + return error_response( + ErrorCodes.NOT_FOUND, + f'USB device {device_id} not found', + http_code=404, + ) + cur.execute( + 'UPDATE devices SET status = %s WHERE device_id = %s', + (_STATUS_RETIRED, device_id), + ) + conn.commit() + + with conn.cursor() as cur: + row = _fetch_device(cur, device_id) + result = _device_to_dict(cur, row) + finally: + conn.close() + + return success_response(result, message='Device retired') + + +# ============================================================================= +# Check-in / check-out operations +# ============================================================================= + +@usb_bp.route('//checkout', methods=['POST']) +@jwt_required() +@require_permission('usb.create') +def checkout_device(device_id): + """Check a device out to a badge.""" + if _usb_selfhosted(): + return selfhosted.checkout_device(device_id, request.get_json() or {}) + data = request.get_json() or {} + + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'badge is required') + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + device = _fetch_device(cur, device_id) + if not device: + return error_response( + ErrorCodes.NOT_FOUND, + f'USB device {device_id} not found', + http_code=404, + ) + if device.get('status') == _STATUS_CHECKED_OUT: + return error_response( + ErrorCodes.CONFLICT, + 'Device is already checked out', + http_code=409, + ) + + name = _resolve_badge_name(cur, badge) + _ensure_user(cur, badge, name) + + locker = data.get('locker_location') + log_locker = locker if locker is not None else device.get('locker_location') + + cur.execute( + 'INSERT INTO checkinoutlog ' + '(badge_number, device_id, action, timestamp, locker_location) ' + "VALUES (%s, %s, 'check-out', %s, %s)", + (badge, device_id, _now(), log_locker), + ) + + if locker is not None: + cur.execute( + 'UPDATE devices SET status = %s, locker_location = %s ' + 'WHERE device_id = %s', + (_STATUS_CHECKED_OUT, locker, device_id), + ) + else: + cur.execute( + 'UPDATE devices SET status = %s WHERE device_id = %s', + (_STATUS_CHECKED_OUT, device_id), + ) + conn.commit() + + with conn.cursor() as cur: + row = _fetch_device(cur, device_id) + result = _device_to_dict(cur, row) + finally: + conn.close() + + return success_response(result, message='Device checked out', http_code=201) + + +@usb_bp.route('//checkin', methods=['POST']) +@jwt_required() +@require_permission('usb.create') +def checkin_device(device_id): + """Check a device back in from a badge.""" + if _usb_selfhosted(): + return selfhosted.checkin_device(device_id, request.get_json() or {}) + data = request.get_json() or {} + + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'badge is required') + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + device = _fetch_device(cur, device_id) + if not device: + return error_response( + ErrorCodes.NOT_FOUND, + f'USB device {device_id} not found', + http_code=404, + ) + if device.get('status') != _STATUS_CHECKED_OUT: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Device is not currently checked out', + http_code=400, + ) + + name = _resolve_badge_name(cur, badge) + _ensure_user(cur, badge, name) + + locker = data.get('locker_location') + log_locker = locker if locker is not None else device.get('locker_location') + sanitized = _flag(data.get('sanitized')) + scanned_viruses = _flag(data.get('scanned_viruses')) + + cur.execute( + 'INSERT INTO checkinoutlog ' + '(badge_number, device_id, action, timestamp, scanned_viruses, ' + 'locker_location, sanitized) ' + "VALUES (%s, %s, 'check-in', %s, %s, %s, %s)", + (badge, device_id, _now(), scanned_viruses, log_locker, sanitized), + ) + + if locker is not None: + cur.execute( + 'UPDATE devices SET status = %s, locker_location = %s ' + 'WHERE device_id = %s', + (_STATUS_CHECKED_IN, locker, device_id), + ) + else: + cur.execute( + 'UPDATE devices SET status = %s WHERE device_id = %s', + (_STATUS_CHECKED_IN, device_id), + ) + conn.commit() + + with conn.cursor() as cur: + row = _fetch_device(cur, device_id) + result = _device_to_dict(cur, row) + finally: + conn.close() + + return success_response(result, message='Device checked in') + + +# ============================================================================= +# Check-in/out log queries +# ============================================================================= + +@usb_bp.route('//history', methods=['GET']) +@jwt_required(optional=True) +def get_device_history(device_id): + """Paginated check-in/out log for one device.""" + if _usb_selfhosted(): + return selfhosted.device_history(device_id, request) + page, per_page = get_pagination_params(request) + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + cur.execute( + 'SELECT COUNT(*) AS total FROM checkinoutlog WHERE device_id = %s', + (device_id,), + ) + total = cur.fetchone()['total'] + + cur.execute( + 'SELECT ' + _LOG_COLS + ' FROM checkinoutlog WHERE device_id = %s ' + 'ORDER BY timestamp DESC, log_id DESC LIMIT %s OFFSET %s', + (device_id, per_page, (page - 1) * per_page), + ) + rows = cur.fetchall() + data = [_log_to_dict(cur, row) for row in rows] + finally: + conn.close() + + return paginated_response(data, page, per_page, total) + + +@usb_bp.route('/checkouts', methods=['GET']) +@jwt_required(optional=True) +def list_all_checkouts(): + """List check-out log rows. + + Query parameters: + - active: true -> only rows whose device is currently checked out + - badge: filter by badge_number + """ + if _usb_selfhosted(): + return selfhosted.list_checkouts(request, active_only=False) + page, per_page = get_pagination_params(request) + + where = ["l.action = 'check-out'"] + params = [] + + if request.args.get('active', '').lower() == 'true': + where.append("d.status = 'checked-out'") + + badge = request.args.get('badge', '').strip() + if badge: + where.append('l.badge_number = %s') + params.append(badge) + + where_sql = ' WHERE ' + ' AND '.join(where) + base = ( + 'FROM checkinoutlog l JOIN devices d ON l.device_id = d.device_id' + + where_sql + ) + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + cur.execute('SELECT COUNT(*) AS total ' + base, params) + total = cur.fetchone()['total'] + + cur.execute( + 'SELECT l.log_id, l.badge_number, l.device_id, l.action, ' + 'l.timestamp, l.scanned_viruses, l.locker_location, l.sanitized, ' + 'd.status AS device_status ' + base + + ' ORDER BY l.timestamp DESC, l.log_id DESC LIMIT %s OFFSET %s', + params + [per_page, (page - 1) * per_page], + ) + rows = cur.fetchall() + data = [_log_to_dict(cur, row) for row in rows] + finally: + conn.close() + + return paginated_response(data, page, per_page, total) + + +@usb_bp.route('/checkouts/active', methods=['GET']) +@jwt_required(optional=True) +def list_active_checkouts(): + """List the latest check-out log row for every currently checked-out device. + + Query parameters: + - badge: filter by badge_number + """ + if _usb_selfhosted(): + return selfhosted.list_checkouts(request, active_only=True) + where_badge = '' + params = [] + badge = request.args.get('badge', '').strip() + if badge: + where_badge = 'AND l.badge_number = %s ' + params.append(badge) + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + cur.execute( + 'SELECT l.log_id, l.badge_number, l.device_id, l.action, ' + 'l.timestamp, l.scanned_viruses, l.locker_location, l.sanitized, ' + 'd.status AS device_status ' + 'FROM devices d ' + 'JOIN checkinoutlog l ON l.device_id = d.device_id ' + "AND l.action = 'check-out' " + "WHERE d.status = 'checked-out' " + + where_badge + + 'AND l.log_id = (' + ' SELECT MAX(l2.log_id) FROM checkinoutlog l2 ' + " WHERE l2.device_id = d.device_id AND l2.action = 'check-out'" + ') ' + 'ORDER BY l.timestamp DESC, l.log_id DESC', + params, + ) + rows = cur.fetchall() + data = [_log_to_dict(cur, row) for row in rows] + finally: + conn.close() + + return success_response(data) diff --git a/plugins/usb/api/selfhosted.py b/plugins/usb/api/selfhosted.py new file mode 100644 index 00000000..4254da32 --- /dev/null +++ b/plugins/usb/api/selfhosted.py @@ -0,0 +1,276 @@ +"""Self-hosted USB check-in/out, backed by the app-owned tables. + +Used when usb_directory_mode=selfhosted: the USB routes call these functions +instead of the external cmmc_usb database. Data lives in the app tables +(usbdevices, usbcheckouts, usbdevicetypes); responses match the same shape the +external path returns so the frontend is identical in both modes. + +Field mapping (response contract <- app model): + device_id <- USBDevice.serialnumber + device_desc <- USBDevice.label + status <- derived: retired (not isactive) / checked-out / checked-in + locker_location <- USBDevice.storagelocation + current_holder <- USBDevice.currentuserid (SSO) + checkout_time <- USBDevice.currentcheckoutdate +History rows are synthesized from usbcheckouts (each row = a check-out event and, +if returned, a check-in event). +""" + +from datetime import datetime, timezone + +from shopdb.api import ( + db, success_response, error_response, ErrorCodes, + get_pagination_params, paginated_response, + apply_import_timestamps, import_mode_active, parse_import_datetime, +) + +from ..models import USBDevice, USBCheckout + +STATUS_CHECKED_IN = 'checked-in' +STATUS_CHECKED_OUT = 'checked-out' +STATUS_RETIRED = 'retired' +_FILTER = {'available': STATUS_CHECKED_IN, 'checkedout': STATUS_CHECKED_OUT, 'retired': STATUS_RETIRED} + + +def _resolve_name(sso): + """Best-effort name from the self-hosted employee directory.""" + try: + from plugins.employees.models import DirectoryEmployee + if sso and str(sso).isdigit(): + emp = db.session.get(DirectoryEmployee, int(sso)) + if emp: + return f'{emp.firstname} {emp.lastname}'.strip() + except Exception: + pass + return None + + +def _status(device): + if not device.isactive: + return STATUS_RETIRED + return STATUS_CHECKED_OUT if device.ischeckedout else STATUS_CHECKED_IN + + +def _device_dict(device): + return { + 'device_id': device.serialnumber, + 'device_desc': device.label, + 'device_owner': None, + 'owner_name': None, + 'status': _status(device), + 'locker_location': device.storagelocation, + 'current_holder': device.currentuserid, + 'current_holder_name': device.currentusername, + 'checkout_time': device.currentcheckoutdate.isoformat() + 'Z' if device.currentcheckoutdate else None, + } + + +def _checkout_events(checkout): + """One usbcheckouts row -> its check-out event (+ check-in event if returned).""" + name = checkout.checkoutname or _resolve_name(checkout.sso) + events = [{ + 'log_id': checkout.checkoutid * 2, + 'badge_number': checkout.sso, + 'badge_name': name, + 'device_id': checkout.device.serialnumber if checkout.device else None, + 'action': 'check-out', + 'timestamp': checkout.checkouttime.isoformat() + 'Z' if checkout.checkouttime else None, + 'scanned_viruses': None, + 'locker_location': None, + 'sanitized': None, + }] + if checkout.checkintime: + events.append({ + 'log_id': checkout.checkoutid * 2 + 1, + 'badge_number': checkout.sso, + 'badge_name': name, + 'device_id': checkout.device.serialnumber if checkout.device else None, + 'action': 'check-in', + 'timestamp': checkout.checkintime.isoformat() + 'Z', + 'scanned_viruses': None, + 'locker_location': None, + 'sanitized': checkout.waswiped, + }) + return events + + +def _find(device_id): + return USBDevice.query.filter_by(serialnumber=device_id).first() + + +# --------------------------------------------------------------------------- + +def list_devices(request): + page, per_page = get_pagination_params(request) + query = USBDevice.query + status_arg = request.args.get('status', '').strip().lower() + if status_arg in _FILTER: + want = _FILTER[status_arg] + if want == STATUS_RETIRED: + query = query.filter_by(isactive=False) + elif want == STATUS_CHECKED_OUT: + query = query.filter_by(isactive=True, ischeckedout=True) + else: + query = query.filter_by(isactive=True, ischeckedout=False) + search = request.args.get('search', '').strip() + if search: + like = f'%{search}%' + query = query.filter(db.or_(USBDevice.serialnumber.ilike(like), + USBDevice.label.ilike(like))) + total = query.count() + rows = (query.order_by(USBDevice.serialnumber) + .limit(per_page).offset((page - 1) * per_page).all()) + return paginated_response([_device_dict(d) for d in rows], page, per_page, total) + + +def get_device(device_id): + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404) + result = _device_dict(device) + checkouts = (USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid) + .order_by(USBCheckout.checkouttime.desc()).limit(20).all()) + logs = [] + for checkout in checkouts: + logs.extend(_checkout_events(checkout)) + logs.sort(key=lambda item: item['timestamp'] or '', reverse=True) + result['checkinoutlog'] = logs[:20] + return success_response(result) + + +def create_device(data): + device_id = (data.get('device_id') or '').strip() + if not device_id: + return error_response(ErrorCodes.VALIDATION_ERROR, 'device_id is required') + if _find(device_id): + return error_response(ErrorCodes.CONFLICT, 'device_id already exists', http_code=409) + device = USBDevice(serialnumber=device_id, label=data.get('device_desc'), + storagelocation=data.get('locker_location'), + ischeckedout=False, isactive=True) + db.session.add(device) + apply_import_timestamps(device, data) + db.session.commit() + return success_response(_device_dict(device), message='Device created', http_code=201) + + +def update_device(device_id, data): + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404) + if 'device_desc' in data: + device.label = data['device_desc'] + if 'locker_location' in data: + device.storagelocation = data['locker_location'] + if 'status' in data: + if data['status'] == STATUS_RETIRED: + device.isactive = False + elif data['status'] in (STATUS_CHECKED_IN, STATUS_CHECKED_OUT): + device.isactive = True + device.ischeckedout = data['status'] == STATUS_CHECKED_OUT + db.session.commit() + return success_response(_device_dict(device), message='Device updated') + + +def retire_device(device_id): + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404) + device.isactive = False + db.session.commit() + return success_response(_device_dict(device), message='Device retired') + + +def checkout_device(device_id, data): + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'badge is required') + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404) + if device.ischeckedout: + return error_response(ErrorCodes.CONFLICT, 'Device is already checked out', http_code=409) + name = _resolve_name(badge) + now = datetime.now(timezone.utc).replace(tzinfo=None) + # Backdated import: an admin import request may pass the historical + # checkouttime so migrated usbcheckouts rows keep their real event time. + eventtime = now + if import_mode_active(): + override = parse_import_datetime(data.get('checkouttime')) + if override is not None: + eventtime = override + db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, sso=badge, + checkoutname=name, checkouttime=eventtime, + checkoutreason=data.get('reason'))) + device.ischeckedout = True + device.currentuserid = badge + device.currentusername = name + device.currentcheckoutdate = eventtime + if data.get('locker_location'): + device.storagelocation = data['locker_location'] + db.session.commit() + return success_response(_device_dict(device), message='Device checked out', http_code=201) + + +def checkin_device(device_id, data): + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'badge is required') + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404) + if not device.ischeckedout: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Device is not checked out') + open_checkout = (USBCheckout.query + .filter_by(usbdeviceid=device.usbdeviceid, checkintime=None) + .order_by(USBCheckout.checkouttime.desc()).first()) + if open_checkout: + checkintime = datetime.now(timezone.utc).replace(tzinfo=None) + # Backdated import: accept the historical checkintime from an admin + # import request so returned checkouts keep their real return time. + if import_mode_active(): + override = parse_import_datetime(data.get('checkintime')) + if override is not None: + checkintime = override + open_checkout.checkintime = checkintime + open_checkout.waswiped = bool(data.get('sanitized')) + open_checkout.checkinnotes = data.get('notes') + device.ischeckedout = False + device.currentuserid = None + device.currentusername = None + device.currentcheckoutdate = None + if data.get('locker_location'): + device.storagelocation = data['locker_location'] + db.session.commit() + return success_response(_device_dict(device), message='Device checked in') + + +def device_history(device_id, request): + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404) + page, per_page = get_pagination_params(request) + checkouts = (USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid) + .order_by(USBCheckout.checkouttime.desc()).all()) + logs = [] + for checkout in checkouts: + logs.extend(_checkout_events(checkout)) + logs.sort(key=lambda item: item['timestamp'] or '', reverse=True) + total = len(logs) + start = (page - 1) * per_page + return paginated_response(logs[start:start + per_page], page, per_page, total) + + +def list_checkouts(request, active_only): + query = USBCheckout.query + if active_only: + query = query.filter_by(checkintime=None) + badge = request.args.get('badge', '').strip() + if badge: + query = query.filter_by(sso=badge) + rows = query.order_by(USBCheckout.checkouttime.desc()).all() + data = [] + for checkout in rows: + event = _checkout_events(checkout)[0] + event['device_status'] = _status(checkout.device) if checkout.device else None + data.append(event) + return success_response(data) diff --git a/plugins/usb/manifest.json b/plugins/usb/manifest.json new file mode 100644 index 00000000..5b595c3e --- /dev/null +++ b/plugins/usb/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "usb", + "display_name": "USB", + "version": "1.0.0", + "description": "USB device checkout management", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/usb", + "default_enabled": false +} \ No newline at end of file diff --git a/plugins/usb/migrations/env.py b/plugins/usb/migrations/env.py new file mode 100644 index 00000000..dfafd6a1 --- /dev/null +++ b/plugins/usb/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the usb 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_usb. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'usb' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/usb/migrations/script.py.mako b/plugins/usb/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/usb/migrations/script.py.mako @@ -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"} diff --git a/plugins/usb/migrations/versions/0001_usb_anchor.py b/plugins/usb/migrations/versions/0001_usb_anchor.py new file mode 100644 index 00000000..64a18c0c --- /dev/null +++ b/plugins/usb/migrations/versions/0001_usb_anchor.py @@ -0,0 +1,28 @@ +"""usb 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_usb. From this +anchor forward, new usb 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 = 'usb0001anchor' +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 diff --git a/plugins/usb/migrations/versions/0002_drop_checkout_machineid.py b/plugins/usb/migrations/versions/0002_drop_checkout_machineid.py new file mode 100644 index 00000000..f1861c6d --- /dev/null +++ b/plugins/usb/migrations/versions/0002_drop_checkout_machineid.py @@ -0,0 +1,42 @@ +"""Drop the dead usbcheckouts.machineid column (ADR-001: Machine retired). + +machineid was a NOT NULL soft-reference to the retired machines table; every +new checkout stored the sentinel 0. It has no FK, no meaning under the asset +model, and no reader. Drop it. + +Idempotent (skips a missing table/column). Downgrade restores it NOT NULL with +server_default 0 so any existing rows stay valid. + +Revision ID: usb0002dropmachineid +Revises: usb0001anchor +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'usb0002dropmachineid' +down_revision = 'usb0001anchor' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'usbcheckouts' not in insp.get_table_names(): + return + cols = {c['name'] for c in insp.get_columns('usbcheckouts')} + if 'machineid' in cols: + op.drop_column('usbcheckouts', 'machineid') + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'usbcheckouts' not in insp.get_table_names(): + return + cols = {c['name'] for c in insp.get_columns('usbcheckouts')} + if 'machineid' not in cols: + op.add_column('usbcheckouts', + sa.Column('machineid', sa.Integer(), nullable=False, + server_default='0')) diff --git a/plugins/usb/models/__init__.py b/plugins/usb/models/__init__.py new file mode 100644 index 00000000..9111ebec --- /dev/null +++ b/plugins/usb/models/__init__.py @@ -0,0 +1,5 @@ +"""USB plugin models.""" + +from .usb_device import USBDevice, USBDeviceType, USBCheckout + +__all__ = ['USBDevice', 'USBDeviceType', 'USBCheckout'] diff --git a/plugins/usb/models/usb_device.py b/plugins/usb/models/usb_device.py new file mode 100644 index 00000000..c549cbba --- /dev/null +++ b/plugins/usb/models/usb_device.py @@ -0,0 +1,167 @@ +"""USB device plugin models.""" + +from datetime import datetime, timezone +from shopdb.api import db, BaseModel, AuditMixin + + +def _utcnow(): + # naive UTC for DB columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class USBDeviceType(BaseModel): + """ + USB device type classification. + + Examples: Flash Drive, External HDD, External SSD, Card Reader + """ + __tablename__ = 'usbdevicetypes' + + usbdevicetypeid = db.Column(db.Integer, primary_key=True) + typename = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), default='usb', comment='Icon name for UI') + + def __repr__(self): + return f"" + + +class USBDevice(BaseModel, AuditMixin): + """ + USB device model. + + Tracks USB storage devices that can be checked out by users. + """ + __tablename__ = 'usbdevices' + + usbdeviceid = db.Column(db.Integer, primary_key=True) + + # Identification + serialnumber = db.Column(db.String(100), unique=True, nullable=False) + label = db.Column(db.String(100), nullable=True, comment='Human-readable label') + assetnumber = db.Column(db.String(50), nullable=True, comment='Optional asset tag') + + # Classification + usbdevicetypeid = db.Column( + db.Integer, + db.ForeignKey('usbdevicetypes.usbdevicetypeid'), + nullable=True + ) + + # Specifications + capacitygb = db.Column(db.Integer, nullable=True, comment='Capacity in GB') + vendorid = db.Column(db.String(10), nullable=True, comment='USB Vendor ID (hex)') + productid = db.Column(db.String(10), nullable=True, comment='USB Product ID (hex)') + manufacturer = db.Column(db.String(100), nullable=True) + productname = db.Column(db.String(100), nullable=True) + + # Current status + ischeckedout = db.Column(db.Boolean, default=False) + currentuserid = db.Column(db.String(50), nullable=True, comment='SSO of current user') + currentusername = db.Column(db.String(100), nullable=True, comment='Name of current user') + currentcheckoutdate = db.Column(db.DateTime, nullable=True) + + # Location + storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out') + + # Security + pin = db.Column(db.String(50), nullable=True, comment='PIN for encrypted devices') + + # Notes + notes = db.Column(db.Text, nullable=True) + + # Relationships + devicetype = db.relationship('USBDeviceType', backref='devices') + + # Indexes + __table_args__ = ( + db.Index('idx_usb_checkedout', 'ischeckedout'), + db.Index('idx_usb_type', 'usbdevicetypeid'), + db.Index('idx_usb_currentuser', 'currentuserid'), + ) + + def __repr__(self): + return f"" + + @property + def display_name(self): + """Get display name (label if set, otherwise serial number).""" + return self.label or self.serialnumber + + def to_dict(self): + """Convert to dictionary with related data.""" + result = super().to_dict() + + # Add type info + if self.devicetype: + result['typename'] = self.devicetype.typename + result['typeicon'] = self.devicetype.icon + + # Add computed property + result['displayname'] = self.display_name + + return result + + +class USBCheckout(BaseModel): + """ + USB device checkout history. + + Tracks when devices are checked out and returned. + Maps to existing usbcheckouts table from classic ShopDB. + """ + __tablename__ = 'usbcheckouts' + + checkoutid = db.Column(db.Integer, primary_key=True) + + # Device reference (new column linking to usbdevices table) + usbdeviceid = db.Column( + db.Integer, + db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'), + nullable=True + ) + + # User info + sso = db.Column(db.String(20), nullable=False, comment='SSO of user') + checkoutname = db.Column(db.String(100), nullable=True, comment='Name of user') + + # Checkout details + checkouttime = db.Column(db.DateTime, nullable=False, default=_utcnow) + checkintime = db.Column(db.DateTime, nullable=True) + + # Metadata + checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout') + checkinnotes = db.Column(db.Text, nullable=True) + waswiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return') + + # Relationships + device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic')) + + def __repr__(self): + return f"" + + @property + def is_active(self): + """Check if this checkout is currently active (not returned).""" + return self.checkintime is None + + @property + def duration_days(self): + """Get duration of checkout in days.""" + end = self.checkintime or datetime.now(timezone.utc).replace(tzinfo=None) + delta = end - self.checkouttime + return delta.days + + def to_dict(self): + """Convert to dictionary with computed fields.""" + result = super().to_dict() + + result['isactivecheckout'] = self.is_active + result['durationdays'] = self.duration_days + + # Add device info if loaded + if self.device: + result['devicelabel'] = self.device.label + result['deviceserialnumber'] = self.device.serialnumber + + return result diff --git a/plugins/usb/plugin.py b/plugins/usb/plugin.py new file mode 100644 index 00000000..85054cae --- /dev/null +++ b/plugins/usb/plugin.py @@ -0,0 +1,120 @@ +"""USB plugin main class.""" + +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 .models import USBDevice, USBDeviceType, USBCheckout +from .api import usb_bp + +logger = logging.getLogger(__name__) + + +class USBPlugin(BasePlugin): + """ + USB plugin - manages USB device tracking and checkouts. + + Standalone plugin for tracking USB flash drives, external drives, + and other portable storage devices with checkout/checkin functionality. + """ + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + 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 plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'usb'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get('description', 'USB device checkout management'), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/usb'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return usb_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [USBDeviceType, USBDevice, USBCheckout] + + def get_provisioning_note(self) -> Optional[Dict]: + return { + 'mode_setting': 'usb_directory_mode', + 'tables': ['usbdevicetypes', 'usbdevices', 'usbcheckouts'], + 'note': ('Enabling this creates USB tracking tables in the shopdb ' + 'database (usbdevicetypes, usbdevices, usbcheckouts) for CMMC ' + 'removable-media check-in/out. Devices, check-in/out events, ' + 'and per-event log ids live here. Planned build-out: a USB-ID ' + 'standard, overdue-device email reminders, and DLP/Fabric ' + 'approval integration (see the captured design notes). A site ' + 'with an existing cmmc_usb database can use external mode ' + 'instead.'), + 'docs': 'plugins/usb/README.md', + } + + def get_config_schema(self) -> List[Dict]: + """CMMC USB check-in/out database connection. Host/name/user are settings + the wizard can edit; the password stays in .env (emitted, not stored).""" + return [ + {'key': 'cmmc_usb_db_host', 'label': 'USB DB host', 'type': 'text', + 'secret': False, 'default': 'localhost', + 'help': 'This DB must expose devices / checkinoutlog / users tables ' + '(or views). See plugins/usb/README.md.'}, + {'key': 'cmmc_usb_db_name', 'label': 'USB DB name', 'type': 'text', + 'secret': False, 'default': 'cmmc_usb'}, + {'key': 'cmmc_usb_db_user', 'label': 'USB DB user', 'type': 'text', + 'secret': False}, + {'key': 'cmmc_usb_db_password', 'label': 'USB DB password', 'type': 'password', + 'secret': True, 'envvar': 'CMMC_USB_DB_PASSWORD', + 'help': 'Stored in .env, not the database. The wizard shows the line to paste.'}, + ] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"USB plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + logger.info("USB plugin installed") + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("USB plugin uninstalled") + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'USB Devices', + 'icon': 'usb', + 'route': '/usb', + 'position': 45, + }, + ] + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('usb.view', 'View USB devices', 'usb'), + ('usb.create', 'Create USB devices', 'usb'), + ('usb.edit', 'Edit USB devices', 'usb'), + ('usb.delete', 'Delete USB devices', 'usb'), + ] diff --git a/plugins/warranty/__init__.py b/plugins/warranty/__init__.py new file mode 100644 index 00000000..e4890064 --- /dev/null +++ b/plugins/warranty/__init__.py @@ -0,0 +1,5 @@ +"""Warranty plugin package.""" + +from .plugin import WarrantyPlugin + +__all__ = ['WarrantyPlugin'] diff --git a/plugins/warranty/api/__init__.py b/plugins/warranty/api/__init__.py new file mode 100644 index 00000000..d76a37f4 --- /dev/null +++ b/plugins/warranty/api/__init__.py @@ -0,0 +1,5 @@ +"""Warranty plugin API.""" + +from .routes import warranty_bp + +__all__ = ['warranty_bp'] diff --git a/plugins/warranty/api/routes.py b/plugins/warranty/api/routes.py new file mode 100644 index 00000000..890e3227 --- /dev/null +++ b/plugins/warranty/api/routes.py @@ -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/', 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('/', 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('/', 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('/', 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('//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, + }) diff --git a/plugins/warranty/manifest.json b/plugins/warranty/manifest.json new file mode 100644 index 00000000..d744e228 --- /dev/null +++ b/plugins/warranty/manifest.json @@ -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"] + } +} diff --git a/plugins/warranty/migrations/env.py b/plugins/warranty/migrations/env.py new file mode 100644 index 00000000..94f4114f --- /dev/null +++ b/plugins/warranty/migrations/env.py @@ -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() diff --git a/plugins/warranty/migrations/script.py.mako b/plugins/warranty/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/warranty/migrations/script.py.mako @@ -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"} diff --git a/plugins/warranty/migrations/versions/0001_warranty_anchor.py b/plugins/warranty/migrations/versions/0001_warranty_anchor.py new file mode 100644 index 00000000..4bbf84bf --- /dev/null +++ b/plugins/warranty/migrations/versions/0001_warranty_anchor.py @@ -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 diff --git a/plugins/warranty/models/__init__.py b/plugins/warranty/models/__init__.py new file mode 100644 index 00000000..a4adec20 --- /dev/null +++ b/plugins/warranty/models/__init__.py @@ -0,0 +1,5 @@ +"""Warranty plugin models.""" + +from .warranty import Warranty, WarrantyAsset, derive_status, STATUS_COLORS + +__all__ = ['Warranty', 'WarrantyAsset', 'derive_status', 'STATUS_COLORS'] diff --git a/plugins/warranty/models/warranty.py b/plugins/warranty/models/warranty.py new file mode 100644 index 00000000..81996209 --- /dev/null +++ b/plugins/warranty/models/warranty.py @@ -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'), + ) diff --git a/plugins/warranty/plugin.py b/plugins/warranty/plugin.py new file mode 100644 index 00000000..49ad7617 --- /dev/null +++ b/plugins/warranty/plugin.py @@ -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})") diff --git a/plugins/warranty/services/__init__.py b/plugins/warranty/services/__init__.py new file mode 100644 index 00000000..efe3743f --- /dev/null +++ b/plugins/warranty/services/__init__.py @@ -0,0 +1,10 @@ +"""Warranty plugin services.""" + +from .providers import ( + get_provider, + provider_names, + ProviderNotConfigured, + WarrantyLookupError, +) + +__all__ = ['get_provider', 'provider_names', 'ProviderNotConfigured', 'WarrantyLookupError'] diff --git a/plugins/warranty/services/providers.py b/plugins/warranty/services/providers.py new file mode 100644 index 00000000..d4ae6c37 --- /dev/null +++ b/plugins/warranty/services/providers.py @@ -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__*), 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()) diff --git a/requirements.in b/requirements.in new file mode 100644 index 00000000..87eb7d14 --- /dev/null +++ b/requirements.in @@ -0,0 +1,32 @@ +# Flask and extensions +flask>=3.0 +flask-sqlalchemy>=3.1 +flask-migrate>=4.0 +flask-jwt-extended>=4.6 +flask-cors>=4.0 +flask-caching>=2.0 +flask-marshmallow>=1.2 +marshmallow-sqlalchemy>=0.29 + +# Database +mysql-connector-python>=8.0 +pymysql>=1.1 + +# CLI and utilities +click>=8.1 +python-dotenv>=1.0 +tabulate>=0.9 + +# HTTP/API clients +requests>=2.31 + +# Security +werkzeug>=3.0 + +# Validation +email-validator>=2.0 + +# Testing +pytest>=7.0 +pytest-flask>=1.2 +pytest-cov>=4.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..d48e1663 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,121 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements.in -o requirements.txt +alembic==1.18.4 + # via flask-migrate +blinker==1.9.0 + # via flask +cachelib==0.13.0 + # via flask-caching +certifi==2026.4.22 + # via requests +charset-normalizer==3.4.7 + # via requests +click==8.3.3 + # via + # -r requirements.in + # flask +coverage==7.13.5 + # via pytest-cov +dnspython==2.8.0 + # via email-validator +email-validator==2.3.0 + # via -r requirements.in +flask==3.1.3 + # via + # -r requirements.in + # flask-caching + # flask-cors + # flask-jwt-extended + # flask-marshmallow + # flask-migrate + # flask-sqlalchemy + # pytest-flask +flask-caching==2.4.0 + # via -r requirements.in +flask-cors==6.0.2 + # via -r requirements.in +flask-jwt-extended==4.7.3 + # via -r requirements.in +flask-marshmallow==1.5.0 + # via -r requirements.in +flask-migrate==4.1.0 + # via -r requirements.in +flask-sqlalchemy==3.1.1 + # via + # -r requirements.in + # flask-migrate +greenlet==3.5.0 + # via sqlalchemy +idna==3.13 + # via + # email-validator + # requests +iniconfig==2.3.0 + # via pytest +itsdangerous==2.2.0 + # via flask +jinja2==3.1.6 + # via flask +mako==1.3.12 + # via alembic +markupsafe==3.0.3 + # via + # flask + # jinja2 + # mako + # werkzeug +marshmallow==4.3.0 + # via + # flask-marshmallow + # marshmallow-sqlalchemy +marshmallow-sqlalchemy==1.5.0 + # via -r requirements.in +mysql-connector-python==9.7.0 + # via -r requirements.in +packaging==26.2 + # via pytest +pluggy==1.6.0 + # via + # pytest + # pytest-cov +pygments==2.20.0 + # via pytest +pyjwt==2.12.1 + # via flask-jwt-extended +pymysql==1.1.3 + # via -r requirements.in +pytest==9.0.3 + # via + # -r requirements.in + # pytest-cov + # pytest-flask +pytest-cov==7.1.0 + # via -r requirements.in +pytest-flask==1.3.0 + # via -r requirements.in +python-dotenv==1.2.2 + # via -r requirements.in +requests==2.33.1 + # via -r requirements.in +sqlalchemy==2.0.49 + # via + # alembic + # flask-sqlalchemy + # marshmallow-sqlalchemy +tabulate==0.10.0 + # via -r requirements.in +typing-extensions==4.15.0 + # via + # alembic + # sqlalchemy +urllib3==2.7.0 + # via requests +werkzeug==3.1.8 + # via + # -r requirements.in + # flask + # flask-cors + # flask-jwt-extended + # pytest-flask +waitress>=3.0 +tzdata diff --git a/scripts/check-naming-and-style.sh b/scripts/check-naming-and-style.sh new file mode 100755 index 00000000..7cadfba6 --- /dev/null +++ b/scripts/check-naming-and-style.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# +# Pre-commit naming + style check for shopdb-flask. +# Enforces CONTRIBUTING.md rules: +# 1. No non-ASCII chars in source (em-dashes, smart quotes, arrows, emojis) +# 2. No banned shorthand identifiers (cfg, ctx, mgr, req, res, env, util, helper) +# as standalone names (suffix usage like printers_bp, request_obj is allowed) +# 3. No snake_case DB column names in __tablename__ or db.Column attrs +# 4. No snake_case API params in frontend that should match DB column names +# +# Exits non-zero if any violation found. +# Skips: venv/, node_modules/, __pycache__/, frontend/dist/, migrations/versions/ + +set -e + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" +cd "$REPO_ROOT" + +VIOLATIONS=0 + +EXCLUDES=( + --exclude-dir=venv + --exclude-dir=node_modules + --exclude-dir=__pycache__ + --exclude-dir=dist + --exclude-dir=.git + --exclude-dir=versions +) + +INCLUDES_CODE=( + --include='*.py' + --include='*.vue' + --include='*.js' + --include='*.ts' +) + +INCLUDES_ALL=( + --include='*.py' + --include='*.vue' + --include='*.js' + --include='*.ts' + --include='*.json' + --include='*.md' + --include='*.yaml' + --include='*.yml' +) + +echo "==> Checking for non-ASCII characters..." +NON_ASCII=$(grep -rPn '[^\x00-\x7F]' "${EXCLUDES[@]}" "${INCLUDES_CODE[@]}" . 2>/dev/null || true) +if [ -n "$NON_ASCII" ]; then + echo "FAIL: non-ASCII characters found (em-dashes, smart quotes, arrows, emojis):" + echo "$NON_ASCII" + echo + VIOLATIONS=$((VIOLATIONS + 1)) +fi + +echo "==> Checking for banned shorthand (standalone)..." +# Match the word as a standalone identifier: not preceded or followed by underscore/word char +# Word boundary in grep is \b but we want to exclude suffix usage like printers_bp +# So: match (^|[^a-zA-Z0-9_])(banned)([^a-zA-Z0-9_]|$) +for word in cfg ctx mgr req res; do + HITS=$(grep -rPn "(^|[^a-zA-Z0-9_])${word}([^a-zA-Z0-9_]|\$)" "${EXCLUDES[@]}" --include='*.py' --include='*.vue' --include='*.js' --include='*.ts' . 2>/dev/null \ + | grep -vP "(^|[^a-zA-Z0-9_])(request_obj|response_obj)" \ + || true) + if [ -n "$HITS" ]; then + echo "FAIL: banned shorthand '$word' (standalone) found:" + echo "$HITS" + echo + VIOLATIONS=$((VIOLATIONS + 1)) + fi +done + +echo "==> Checking for snake_case DB tablenames..." +SNAKE_TABLES=$(grep -rPn "__tablename__\s*=\s*['\"][^'\"]*_" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null || true) +if [ -n "$SNAKE_TABLES" ]; then + echo "FAIL: snake_case __tablename__ found (must be lowercase concatenated):" + echo "$SNAKE_TABLES" + echo + VIOLATIONS=$((VIOLATIONS + 1)) +fi + +echo "==> Checking for snake_case DB column attrs..." +SNAKE_COLS=$(grep -rPn "^\s+[a-z]+_[a-z_]+\s*=\s*db\.Column" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null || true) +if [ -n "$SNAKE_COLS" ]; then + echo "FAIL: snake_case db.Column attribute found (must match column name, no underscores):" + echo "$SNAKE_COLS" + echo + VIOLATIONS=$((VIOLATIONS + 1)) +fi + +echo "==> Checking for snake_case ForeignKey targets..." +SNAKE_FK=$(grep -rPn "ForeignKey\(['\"][^'\"]*_[^'\"]*['\"]" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null || true) +if [ -n "$SNAKE_FK" ]; then + echo "FAIL: snake_case ForeignKey target found:" + echo "$SNAKE_FK" + echo + VIOLATIONS=$((VIOLATIONS + 1)) +fi + +echo "==> Checking for snake_case API params in frontend (DB-mirrored fields)..." +SNAKE_FE=$(grep -rPn "params\.(machine_id|location_id|vendor_id|type_id|business_unit_id|model_id|status_id|operating_system_id|asset_id|user_id|is_active|is_shopfloor)" "${EXCLUDES[@]}" --include='*.vue' --include='*.js' --include='*.ts' . 2>/dev/null || true) +if [ -n "$SNAKE_FE" ]; then + echo "FAIL: snake_case API params in frontend (must match DB column names without underscores):" + echo "$SNAKE_FE" + echo + VIOLATIONS=$((VIOLATIONS + 1)) +fi + +if [ "$VIOLATIONS" -gt 0 ]; then + echo "==================================================" + echo "$VIOLATIONS naming/style violation(s) found." + echo "See CONTRIBUTING.md for the full convention." + echo "==================================================" + exit 1 +fi + +echo "==> All naming/style checks passed." +exit 0 diff --git a/scripts/migration/README.md b/scripts/migration/README.md new file mode 100644 index 00000000..a519b3da --- /dev/null +++ b/scripts/migration/README.md @@ -0,0 +1,24 @@ +# scripts/migration (superseded) + +The direct-SQL migrators that used to live here (`migrate_assets.py`, +`migrate_communications.py`, `migrate_notifications.py`, `migrate_usb.py`, +`run_migration.py`, `verify_migration.py`) and `scripts/import_from_mysql.py` +were **removed** - they had drifted badly out of sync with the current schema +(they targeted a nonexistent `equipment` table, the retired `Machine` model, and +columns that no longer exist) and were actively misleading. + +## Use the import API + a site loader instead + +Legacy data is imported through the maintained, schema-agnostic contract in +the IMPORT-API page in the project wiki (create through real endpoints +with an admin PAT + `X-Import-Mode`). Each adopting site writes a thin loader +that reads its own source database and drives that API. + +- Reference implementation: [`scripts/site_imports/wjf/`](../site_imports/wjf/) + (West Jefferson classic-ASP -> flask). +- Adoption playbook: the IMPORT-ADOPTION page in the project wiki. + +## What's kept here + +- `fix_legacy_schema.sql` - one-time SQL fixups against a legacy source DB. +- `one-offs/` - individual one-shot SQL snippets (see its README). diff --git a/scripts/migration/__init__.py b/scripts/migration/__init__.py new file mode 100644 index 00000000..1d5670f0 --- /dev/null +++ b/scripts/migration/__init__.py @@ -0,0 +1 @@ +"""Data migration scripts for VBScript ShopDB to Flask migration.""" diff --git a/scripts/migration/fix_legacy_schema.sql b/scripts/migration/fix_legacy_schema.sql new file mode 100644 index 00000000..1ddd2068 --- /dev/null +++ b/scripts/migration/fix_legacy_schema.sql @@ -0,0 +1,88 @@ +-- ============================================================================= +-- Legacy Schema Migration Script +-- Adds missing columns to make VBScript ShopDB compatible with Flask ShopDB +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- appowners table +-- ----------------------------------------------------------------------------- +ALTER TABLE appowners + ADD COLUMN IF NOT EXISTS email VARCHAR(100) NULL, + ADD COLUMN IF NOT EXISTS phone VARCHAR(50) NULL, + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1; + +-- ----------------------------------------------------------------------------- +-- pctypes / computertypes alignment +-- ----------------------------------------------------------------------------- +-- The Flask app uses pctype table but expects certain columns +ALTER TABLE pctype + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(); + +-- Ensure isactive is correct type +ALTER TABLE pctype MODIFY COLUMN isactive TINYINT(1) DEFAULT 1; + +-- ----------------------------------------------------------------------------- +-- statuses table (if needed - Flask uses assetstatuses) +-- ----------------------------------------------------------------------------- +-- assetstatuses already populated earlier + +-- ----------------------------------------------------------------------------- +-- subnets table +-- ----------------------------------------------------------------------------- +ALTER TABLE subnets + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1; + +-- ----------------------------------------------------------------------------- +-- vlans table +-- ----------------------------------------------------------------------------- +ALTER TABLE vlans + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1; + +-- ----------------------------------------------------------------------------- +-- usbdevices table +-- ----------------------------------------------------------------------------- +ALTER TABLE usbdevices + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1; + +-- ----------------------------------------------------------------------------- +-- usbcheckouts table +-- ----------------------------------------------------------------------------- +ALTER TABLE usbcheckouts + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(); + +-- ----------------------------------------------------------------------------- +-- notifications table +-- ----------------------------------------------------------------------------- +ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(); + +-- Copy dates from existing columns if they exist +UPDATE notifications SET createddate = startdate WHERE createddate IS NULL; +UPDATE notifications SET modifieddate = startdate WHERE modifieddate IS NULL; + +-- ----------------------------------------------------------------------------- +-- Verify key counts +-- ----------------------------------------------------------------------------- +SELECT 'Migration complete. Record counts:' as status; +SELECT 'vendors' as tbl, COUNT(*) as cnt FROM vendors +UNION ALL SELECT 'models', COUNT(*) FROM models +UNION ALL SELECT 'machinetypes', COUNT(*) FROM machinetypes +UNION ALL SELECT 'operatingsystems', COUNT(*) FROM operatingsystems +UNION ALL SELECT 'businessunits', COUNT(*) FROM businessunits +UNION ALL SELECT 'applications', COUNT(*) FROM applications +UNION ALL SELECT 'machines', COUNT(*) FROM machines +UNION ALL SELECT 'printers', COUNT(*) FROM printers +UNION ALL SELECT 'assets', COUNT(*) FROM assets +UNION ALL SELECT 'knowledgebase', COUNT(*) FROM knowledgebase +UNION ALL SELECT 'notifications', COUNT(*) FROM notifications; diff --git a/scripts/migration/one-offs/README.md b/scripts/migration/one-offs/README.md new file mode 100644 index 00000000..67f2c832 --- /dev/null +++ b/scripts/migration/one-offs/README.md @@ -0,0 +1,16 @@ +# One-off migration scripts (historical) + +These are hand-run SQL scripts kept for reference. They are NOT part of the +Alembic chain (`migrations/versions/`) and are not run by `flask db upgrade`. A +fresh install never needs them; they exist for databases that predate the +corresponding change. + +- `add_recertification_type.sql` - inserts the blue "Recertification" + notification type into an already-existing database. Historical: the + notifications plugin now seeds this type on install + (`plugins/notifications/plugin.py`), so any fresh install already has it. Only + a pre-existing DB that was never re-installed needs this script. Idempotent. + +A related one-off, widening `notifications.employeesso` / `employeename` to +TEXT, has been removed because it is fully superseded by Alembic migration +`7d02_widen_notification_employee_cols`. diff --git a/scripts/migration/one-offs/add_recertification_type.sql b/scripts/migration/one-offs/add_recertification_type.sql new file mode 100644 index 00000000..4bec0ea6 --- /dev/null +++ b/scripts/migration/one-offs/add_recertification_type.sql @@ -0,0 +1,21 @@ +-- Recertification notification type (shopdb-flask) +-- +-- Employee-photo type (blue, like recognition) for employees who must retake a +-- training course. The shopfloor feed splits it one card per employee and the +-- dashboard shows it in a blue "Recertification Required" carousel; the display +-- window is two weeks (computed server-side in the notifications API). +-- +-- Idempotent - safe to re-run. The notifications plugin also seeds this type on +-- install (plugins/notifications/plugin.py); this file is for databases that +-- already exist and are not re-installed. + +INSERT INTO notificationtypes (typename, typedescription, typecolor, isactive) +SELECT * FROM ( + SELECT 'Recertification' AS typename, + 'Employees due to retake a training course (shows two weeks)' AS typedescription, + 'recertification' AS typecolor, + 1 AS isactive +) AS t +WHERE NOT EXISTS ( + SELECT 1 FROM notificationtypes WHERE typename = 'Recertification' +); diff --git a/scripts/site_imports/__init__.py b/scripts/site_imports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/site_imports/wjf/README.md b/scripts/site_imports/wjf/README.md new file mode 100644 index 00000000..0a2a9f09 --- /dev/null +++ b/scripts/site_imports/wjf/README.md @@ -0,0 +1,48 @@ +# WJ classic-ASP import loader (reference implementation) + +This is **site-specific reference glue**, not product code. It maps West +Jefferson's classic-ASP `shopdb` / `cmmc_usb` / `wjf_employees` schema onto the +maintained, schema-agnostic import contract in the IMPORT-API wiki page. + +**Every adopting site has its own source database.** Nobody else runs this +loader. Instead, copy the pattern: + +1. Point the harness at your source DB(s) (edit `harness.Source`). +2. Write per-entity stages that read your tables and POST to the same + the IMPORT-API wiki page endpoints with `Authorization: Bearer ` and + `X-Import-Mode: true`. +3. Persist legacy-id -> new-id crosswalks (see `harness.IdMap`) so later stages + resolve foreign keys and a crashed run resumes. + +The import API is the stable contract; loaders are per-site. The mapping itself +is the bulk of the work: enumerate the source tables against this contract and +write one stage per entity (this loader is the worked example). + +## Running (against a THROWAWAY import database) + +```bash +DATABASE_URL='mysql+pymysql://root:PW@127.0.0.1:3306/shopdb_flask_import?charset=utf8mb4' \ + venv/bin/python -m scripts.site_imports.wjf.run --stages reference,employees +``` + +Prereqs: a fresh target DB built with `flask db upgrade` + `flask plugin +upgrade-all` + `flask seed permissions/settings/reference-data`, and the three +source dumps loaded into scratch DBs (`shopdb_src`, `cmmc_usb_src`, +`wjf_employees_src`). See `scratchpad/IMPORT-PLAN.md` for the full mapping, +resolved decisions, and remaining stages. + +## Status + +Complete - all 15 stages built and verified end-to-end against a fresh scratch +target (zero endpoint errors): `reference`, `employees`, `catalog`, `assets` +(the hub + machineid->assetid crosswalk), `locations`, `printers`, +`communications`, `applications` (+ installs), `warranties`, `notifications`, +`knowledgebase`, `relationships`, `subnets`, `usb`, `verify`. + +Last full run: 983 assets (computer 663, machine 76, network 58, measuring-tool +136, printer 50), 24 locations, 415 employees, 850 installs, 461 primary IPs, +464 warranties, 261 notifications, 341 KB, 93 relationships, 37 subnets, 18 USB +devices + 232 events. UI spot-check passed (computer list + shop-floor map). + +Known follow-ups: general asset `locationid` is null (no source column outside +printers); the ~146 duplicate machinenumbers are first-wins-skipped by decision. diff --git a/scripts/site_imports/wjf/__init__.py b/scripts/site_imports/wjf/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/site_imports/wjf/harness.py b/scripts/site_imports/wjf/harness.py new file mode 100644 index 00000000..abb5b830 --- /dev/null +++ b/scripts/site_imports/wjf/harness.py @@ -0,0 +1,177 @@ +"""Import harness for the WJ classic-ASP -> flask reference loader. + +This is SITE-SPECIFIC reference glue, NOT product code. It maps West Jefferson's +classic schema onto the maintained, schema-agnostic import contract in +the IMPORT-API wiki page. Other sites copy this pattern against their own source DB; +they do not run this loader. + +The harness builds the flask app against the current DATABASE_URL (point it at a +throwaway import database), mints an unscoped admin PAT in-process, and drives +the REAL import endpoints through the app test client with the X-Import-Mode +header - so it exercises the same routes/authz/validation an HTTP client would, +without needing a running server. Legacy-id -> new-id maps persist to JSON so a +crashed run resumes and later stages resolve foreign keys. +""" + +import json +import os +import pymysql + +from shopdb import create_app +from shopdb.extensions import db + + +def _source_password(): + """Root password for the scratch source DBs: reuse the dev DATABASE_URL in + .env unless SOURCE_DB_PASSWORD overrides.""" + if os.environ.get('SOURCE_DB_PASSWORD'): + return os.environ['SOURCE_DB_PASSWORD'] + env = os.path.join(os.path.dirname(__file__), '..', '..', '..', '.env') + with open(os.path.abspath(env)) as handle: + for line in handle: + if line.startswith('DATABASE_URL='): + after = line.split('root:', 1)[1] + return after.split('@', 1)[0] + raise RuntimeError('could not resolve source DB password') + + +class Source: + """Read-only access to the three scratch source databases.""" + + def __init__(self, host='127.0.0.1', port=3306, user='root', password=None): + self.password = password or _source_password() + self.host, self.port, self.user = host, port, user + self._conns = {} + + def db(self, name): + if name not in self._conns: + self._conns[name] = pymysql.connect( + host=self.host, port=self.port, user=self.user, + password=self.password, database=name, charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor) + return self._conns[name] + + def rows(self, dbname, sql, args=None): + with self.db(dbname).cursor() as cur: + cur.execute(sql, args or ()) + return cur.fetchall() + + +class IdMap: + """Persisted legacy-id -> new-id crosswalks, one namespace per entity.""" + + def __init__(self, path): + self.path = path + self._data = {} + if os.path.exists(path): + with open(path) as handle: + self._data = json.load(handle) + + def put(self, namespace, legacy, new): + self._data.setdefault(namespace, {})[str(legacy)] = new + self.save() + + def get(self, namespace, legacy, default=None): + return self._data.get(namespace, {}).get(str(legacy), default) + + def has(self, namespace, legacy): + return str(legacy) in self._data.get(namespace, {}) + + def count(self, namespace): + return len(self._data.get(namespace, {})) + + def save(self): + with open(self.path, 'w') as handle: + json.dump(self._data, handle, indent=1) + + +class Harness: + """App + admin PAT + import-mode test client + id maps.""" + + def __init__(self, idmap_path=None): + self.app = create_app() + self.appcontext = self.app.app_context() + self.appcontext.push() + self._silence_sql_logging() + self.client = self.app.test_client() + self._ensure_asset_types() + self.secret = self._mint_admin_pat() + default = os.path.join(os.path.dirname(__file__), 'idmap.json') + self.ids = IdMap(idmap_path or default) + self.source = Source() + self.errors = [] + + def _silence_sql_logging(self): + import logging + logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING) + + def _ensure_asset_types(self): + """Register each plugin's AssetType + seeded subtypes. on_install (which + does this) is skipped when a DB is built with `flask plugin upgrade-all` + rather than a fresh `install`, so run it idempotently here - the create + routes 500 without the AssetType row.""" + manager = self.app.extensions.get('plugin_manager') + if not manager: + return + for _name, plugin in manager.get_all_plugins().items(): + try: + plugin.on_install(self.app) + except Exception: + pass + # A full import IS the setup, so mark it complete - otherwise the admin + # is bounced to the first-run wizard on every fresh import DB. + from shopdb.core.models import Setting + Setting.set('setup_complete', 'true', valuetype='boolean', category='site') + db.session.commit() + + def _mint_admin_pat(self): + """Create (or reuse) an admin user and an unscoped admin PAT. Unscoped + because import mode + role gates require full owner authority + (the IMPORT-API wiki page). Returns the clear secret.""" + from werkzeug.security import generate_password_hash + from shopdb.core.models import User, Role, ApiToken + admin = User.query.join(User.roles).filter(Role.rolename == 'admin').first() + if not admin: + role = Role.query.filter_by(rolename='admin').first() \ + or Role(rolename='admin', description='Administrator') + db.session.add(role) + admin = User(username='importer', email='importer@localhost', + passwordhash=generate_password_hash('x'), isactive=True) + admin.roles.append(role) + db.session.add(admin) + db.session.commit() + secret = ApiToken.generate_secret() + token = ApiToken(userid=admin.userid, name='wjf legacy import', + tokenprefix=ApiToken.prefix_of(secret), + tokenhash=ApiToken.hash_secret(secret), scopes=None) + db.session.add(token) + db.session.commit() + return secret + + def _headers(self, import_mode): + # A PAT authenticates as Authorization: Bearer shopdb_pat_... (the + # before_request shim resolves it into a JWT identity). X-API-Key is for + # collector/service tokens, not personal tokens. + headers = {'Authorization': f'Bearer {self.secret}'} + if import_mode: + headers['X-Import-Mode'] = 'true' + return headers + + def post(self, path, payload, import_mode=True): + """POST to an import endpoint. Returns (status, data). 409s are returned + (idempotency handled by the caller), other 4xx/5xx are recorded.""" + resp = self.client.post(path, json=payload, headers=self._headers(import_mode)) + body = resp.get_json() or {} + data = body.get('data', body) + if resp.status_code >= 400 and resp.status_code != 409: + self.errors.append((path, resp.status_code, payload, data)) + return resp.status_code, data + + def get(self, path): + resp = self.client.get(path, headers=self._headers(False)) + body = resp.get_json() or {} + return resp.status_code, body.get('data', body) + + def close(self): + self.ids.save() + self.appcontext.pop() diff --git a/scripts/site_imports/wjf/run.py b/scripts/site_imports/wjf/run.py new file mode 100644 index 00000000..fa88ec57 --- /dev/null +++ b/scripts/site_imports/wjf/run.py @@ -0,0 +1,817 @@ +"""WJ classic-ASP -> flask reference import loader (site-specific). + +Run against a THROWAWAY import database: + + DATABASE_URL='mysql+pymysql://root:PW@127.0.0.1:3306/shopdb_flask_import?charset=utf8mb4' \\ + venv/bin/python -m scripts.site_imports.wjf.run --stages reference,employees + +Stages are idempotent (re-running resolves existing rows by natural key), so a +crashed run resumes. See scripts/site_imports/wjf/harness.py for the contract +this drives, and scratchpad/IMPORT-PLAN.md for the full mapping + decisions. + +Implemented: reference (vendors, businessunits, operatingsystems), employees. +TODO stages (assets hub + dependents + network + usb) are stubbed - they need +the machineid->assetid crosswalk this harness persists. +""" + +import argparse + +from .harness import Harness + + +def _id_of(row, idfield): + if not isinstance(row, dict): + return None + if row.get(idfield) is not None: + return row[idfield] + # asset-create responses nest the id at the top level (assetid) too + for key in (idfield, 'id', 'assetid'): + if row.get(key) is not None: + return row[key] + return None + + +def _upsert(h, path, payload, unique_field, idfield, list_path=None): + """POST payload; on 409 resolve the existing row by its unique field. + Returns the new/existing id, or None.""" + status, data = h.post(path, payload) + if status in (200, 201): + return _id_of(data, idfield) + if status == 409: + lookup = list_path or path + lookup += ('&' if '?' in lookup else '?') + 'per_page=10000' + _, rows = h.get(lookup) + items = rows if isinstance(rows, list) else rows.get('items', rows) + for row in (items or []): + if str(row.get(unique_field, '')).strip().lower() == \ + str(payload[unique_field]).strip().lower(): + return _id_of(row, idfield) + return None + + +# --- classic machinetype routing (per the resolved import decisions) --------- +# measuringtools are the physical instruments (the CMM/gauge machine types). A +# PC that DRIVES one (pctype CMM/Genspect/Keyence/Wax) is still a computer, not a +# measuring tool - routing by pctype wrongly swept ~105 PCs into measuringtools. +MEASURING_MTYPES = {3, 5, 8, 23, 47, 48} # CMM, Wax, Eddy, Measuring, Inspection, Spline +COMPUTER_MTYPES = {33, 20} # PC, Server +NETWORK_MTYPES = {16, 17, 18, 19, 46} # Access, IDF, Camera, Switch, Firewall +SKIP_MTYPES = {15, 44} # Printer (printers table), USB (cmmc source) +# LocationOnly is the islocationonly BIT, not machinetypeid=1: of the 158 type-1 +# rows only 24 carry the bit (real named areas). The other 134 are active, +# modelled shop machines just left untyped - they must become assets, not +# Locations. + + +def _truthy_bit(value): + """MySQL bit(1) comes back from pymysql as bytes b'\\x00'/b'\\x01'.""" + return value not in (0, None, False, b'\x00', b'', '0', '') +# classic controllertypeid -> (vendor, model) hand split; 1=TBD dropped +CONTROLLER_SPLIT = {2: ('Fanuc', '31i-MB'), 6: ('Fanuc', None), + 7: ('Okuma', None), 8: ('Makino', None)} + + +def _route(machinetypeid, pctypeid, islocationonly=False): + if islocationonly: + return 'location' + if machinetypeid in SKIP_MTYPES: + return 'skip' + if machinetypeid in NETWORK_MTYPES: + return 'network' + if machinetypeid in MEASURING_MTYPES: + return 'measuringtool' + if machinetypeid in COMPUTER_MTYPES: + return 'computer' # a metrology PC is still a PC (it CONTROLS a tool) + return 'machine' # includes the 134 untyped (machinetypeid=1) shop machines + + +def _modeltype_category(machinetypeid): + if machinetypeid in COMPUTER_MTYPES: + return 'PC' + if machinetypeid in NETWORK_MTYPES: + return 'Network' + if machinetypeid == 15: + return 'Printer' + return 'Equipment' + + +def stage_reference(h): + """Seed the reference tier that later FKs resolve against, capturing + legacy-id -> new-id crosswalks.""" + counts = {} + + vendors = h.source.rows('shopdb_src', + 'SELECT vendorid, vendor FROM vendors WHERE isactive=1') + for v in vendors: + newid = _upsert(h, '/api/vendors', {'vendor': v['vendor']}, 'vendor', 'vendorid') + if newid: + h.ids.put('vendor', v['vendorid'], newid) + counts['vendors'] = h.ids.count('vendor') + + bus = h.source.rows('shopdb_src', + 'SELECT businessunitid, businessunit FROM businessunits WHERE isactive=1') + for b in bus: + newid = _upsert(h, '/api/businessunits', {'businessunit': b['businessunit']}, + 'businessunit', 'businessunitid') + if newid: + h.ids.put('businessunit', b['businessunitid'], newid) + counts['businessunits'] = h.ids.count('businessunit') + + oses = h.source.rows('shopdb_src', + 'SELECT osid, operatingsystem FROM operatingsystems') + for o in oses: + name = (o['operatingsystem'] or '').strip() + if not name: + continue + newid = _upsert(h, '/api/operatingsystems', {'osname': name}, 'osname', 'osid') + if newid: + h.ids.put('os', o['osid'], newid) + counts['operatingsystems'] = h.ids.count('os') + + return counts + + +def stage_employees(h): + """Bulk-upsert the employee directory (selfhosted). Photos are deferred + (per the import decisions); occurrences are out of scope.""" + from shopdb.core.models import Setting + Setting.set('employee_directory_mode', 'selfhosted', valuetype='string', + category='employees') + from shopdb.extensions import db + db.session.commit() + + rows = h.source.rows('wjf_employees_src', + 'SELECT SSO, First_Name, Last_Name, Team, Role FROM employees') + lines = ['SSO,First_Name,Last_Name,Team,Role'] + for r in rows: + vals = [str(r['SSO']), (r['First_Name'] or '').strip(), + (r['Last_Name'] or '').strip(), (r['Team'] or '').strip(), + (r['Role'] or '').strip()] + lines.append(','.join(v.replace(',', ' ') for v in vals)) + status, data = h.post('/api/employees/directory/import', {'csv': '\n'.join(lines)}) + return {'employees_posted': len(rows), 'result': data} + + +def stage_catalog(h): + """Seed the type catalogs the asset routing + models depend on, capturing + crosswalks: modeltypes + per-plugin subtypes (from classic machinetypes), + computer subtypes (from pctype), controller vendor/model split, models.""" + counts = {} + mtypes = h.source.rows('shopdb_src', + 'SELECT machinetypeid, machinetype FROM machinetypes') + for mt in mtypes: + name = (mt['machinetype'] or '').strip() + mtid = mt['machinetypeid'] + # machinetypeid=1 (LocationOnly) is not a real machine subtype - the + # untyped shop machines that carry it import with a null subtype. + if not name or mtid == 1: + continue + # modeltype (types the models catalog) + modeltypeid = _upsert(h, '/api/modeltypes', + {'modeltype': name, 'category': _modeltype_category(mtid)}, + 'modeltype', 'modeltypeid') + if modeltypeid: + h.ids.put('modeltype', mtid, modeltypeid) + # per-plugin asset subtype, by route + route = _route(mtid, None) + if route == 'machine': + sid = _upsert(h, '/api/machines/types', {'machinetype': name}, + 'machinetype', 'machinetypeid', '/api/machines/types') + if sid: + h.ids.put('machinesubtype', mtid, sid) + elif route == 'network': + sid = _upsert(h, '/api/network/types', {'networkdevicetype': name}, + 'networkdevicetype', 'networkdevicetypeid', '/api/network/types') + if sid: + h.ids.put('networksubtype', mtid, sid) + elif route == 'measuringtool': + sid = _upsert(h, '/api/measuringtools/types', {'name': name}, + 'name', 'measuringtooltypeid', '/api/measuringtools/types') + if sid: + h.ids.put('measuringsubtype', mtid, sid) + counts['modeltypes'] = h.ids.count('modeltype') + + # computer subtypes from pctype + for pc in h.source.rows('shopdb_src', 'SELECT pctypeid, typename FROM pctype'): + name = (pc['typename'] or '').strip() + if not name: + continue + sid = _upsert(h, '/api/computers/types', {'computertype': name}, + 'computertype', 'computertypeid', '/api/computers/types') + if sid: + h.ids.put('computertype', pc['pctypeid'], sid) + counts['computertypes'] = h.ids.count('computertype') + + # controller split -> vendor + model, crosswalk controllertypeid -> both ids + for ctid, (vendor, model) in CONTROLLER_SPLIT.items(): + vid = _upsert(h, '/api/vendors', {'vendor': vendor}, 'vendor', 'vendorid') + mid = None + if model and vid: + mid = _upsert(h, '/api/models', {'modelnumber': model, 'vendorid': vid}, + 'modelnumber', 'modelnumberid') + h.ids.put('controllervendor', ctid, vid) + if mid: + h.ids.put('controllermodel', ctid, mid) + + # models catalog (the only vendor source for machines) + for m in h.source.rows('shopdb_src', + 'SELECT modelnumberid, modelnumber, vendorid, machinetypeid ' + 'FROM models WHERE isactive=1'): + number = (m['modelnumber'] or '').strip() + if not number: + continue + payload = {'modelnumber': number, + 'vendorid': h.ids.get('vendor', m['vendorid']), + 'modeltypeid': h.ids.get('modeltype', m['machinetypeid'])} + newid = _upsert(h, '/api/models', payload, 'modelnumber', 'modelnumberid') + if newid: + h.ids.put('model', m['modelnumberid'], newid) + counts['models'] = h.ids.count('model') + return counts + + +def stage_assets(h): + """The hub: fan classic machines out to the right asset endpoint by type, + persisting the machineid -> assetid crosswalk everything downstream needs. + LocationOnly/printer/usb rows are skipped here (handled elsewhere / TODO).""" + # isactive=1 only: retired machines (classic keeps them as history) must not + # land as live assets. Every other stage already filters isactive=1; the + # assets hub is the one that leaked retired rows (incl. G-prefix hostnames). + machines = h.source.rows('shopdb_src', 'SELECT * FROM machines WHERE isactive=1') + seen_assetnumbers = set() + counts = {'computer': 0, 'machine': 0, 'network': 0, 'measuringtool': 0, + 'skip_location': 0, 'skip_other': 0, 'skip_dup': 0, 'skip_9999': 0} + + # Pre-seed assetnumbers already in the target (idempotent re-run). + for route in ('computers', 'machines', 'network', 'measuringtools'): + _, rows = h.get(f'/api/{route}?per_page=5000') + for row in (rows.get('items', rows) if isinstance(rows, dict) else rows) or []: + if isinstance(row, dict) and row.get('assetnumber'): + seen_assetnumbers.add(row['assetnumber'].strip().lower()) + + # On a duplicate machinenumber, first-wins - so process the richer asset + # types first (a PC carries installs/IP a bare untyped machine does not). + route_rank = {'computer': 0, 'measuringtool': 1, 'network': 2, 'machine': 3} + machines = sorted(machines, key=lambda m: route_rank.get( + _route(m['machinetypeid'], m['pctypeid'], _truthy_bit(m['islocationonly'])), 4)) + + for m in machines: + route = _route(m['machinetypeid'], m['pctypeid'], + _truthy_bit(m['islocationonly'])) + if route == 'location': + counts['skip_location'] += 1 + continue + if route == 'skip': + counts['skip_other'] += 1 + continue + + raw = (m['machinenumber'] or '').strip() + if raw == '9999': + counts['skip_9999'] += 1 + continue + assetnumber = raw or (m['hostname'] or '').strip() + if not assetnumber: + counts['skip_other'] += 1 + continue + key = assetnumber.lower() + if key in seen_assetnumbers: + counts['skip_dup'] += 1 + continue + seen_assetnumbers.add(key) + + base = { + 'assetnumber': assetnumber, + 'name': (m['alias'] or m['hostname'] or '').strip() or None, + 'serialnumber': (m['serialnumber'] or '').strip() or None, + 'businessunitid': h.ids.get('businessunit', m['businessunitid']), + 'mapx': m['mapleft'], 'mapy': m['maptop'], + 'notes': m['machinenotes'], + 'dateadded': str(m['dateadded']) if m['dateadded'] else None, + 'modifieddate': str(m['lastupdated']) if m['lastupdated'] else None, + } + if route == 'computer': + path = '/api/computers' + payload = {**base, 'hostname': (m['hostname'] or '').strip() or None, + 'computertypeid': h.ids.get('computertype', m['pctypeid']), + 'osid': h.ids.get('os', m['osid']), + 'ipaddress': (m['ipaddress1'] or '').strip() or None} + elif route == 'network': + path = '/api/network' + payload = {**base, 'hostname': (m['hostname'] or m['fqdn'] or '').strip() or None, + 'networkdevicetypeid': h.ids.get('networksubtype', m['machinetypeid']), + 'vendorid': None, + 'ipaddress': (m['ipaddress1'] or '').strip() or None} + elif route == 'measuringtool': + path = '/api/measuringtools' + payload = {**base, + 'measuringtooltypeid': h.ids.get('measuringsubtype', m['machinetypeid'])} + else: # machine + path = '/api/machines' + payload = {**base, 'machinetypeid': h.ids.get('machinesubtype', m['machinetypeid']), + 'modelnumberid': h.ids.get('model', m['modelnumberid']), + 'vendorid': None, + 'controllervendorid': h.ids.get('controllervendor', m['controllertypeid']), + 'controllermodelid': h.ids.get('controllermodel', m['controllertypeid'])} + + status, data = h.post(path, payload) + if status in (200, 201): + assetid = _id_of(data, 'assetid') + if assetid: + h.ids.put('asset', m['machineid'], assetid) + counts[route] += 1 + return counts + + +def stage_printers(h): + """Printers come from the printers TABLE (not the machines hub). assetnumber + is synthesized PRN-{printerid} (no machinenumber). The printer's IP folds + into a Communication via the create route. Location resolves from the host + machineid when it is one of the LocationOnly rows. Skips inactive.""" + made = 0 + for p in h.source.rows('shopdb_src', + 'SELECT printerid, modelid, printerwindowsname, printercsfname, ' + 'serialnumber, fqdn, ipaddress, machineid, maptop, mapleft, iscsf, ' + 'installpath, printernotes, printerpin FROM printers WHERE isactive=1'): + payload = { + 'assetnumber': f"PRN-{p['printerid']}", + 'name': (p['printerwindowsname'] or '').strip() or None, + 'serialnumber': (p['serialnumber'] or '').strip() or None, + 'hostname': (p['fqdn'] or '').strip() or None, + 'windowsname': (p['printerwindowsname'] or '').strip() or None, + 'sharename': (p['printercsfname'] or '').strip() or None, + 'ipaddress': (p['ipaddress'] or '').strip() or None, + 'iscsf': _truthy_bit(p['iscsf']), + 'installpath': p['installpath'], 'pin': p['printerpin'], + 'notes': p['printernotes'], 'mapx': p['mapleft'], 'mapy': p['maptop'], + 'modelnumberid': h.ids.get('model', p['modelid']), + 'locationid': h.ids.get('location', p['machineid']), + } + status, _ = h.post('/api/printers', payload) + if status in (200, 201): + made += 1 + return {'printers': made} + + +# classic pctype -> the measuring instrument that PC drives +METROLOGY_PCTYPE_TOOL = {5: 'CMM', 6: 'Form Tracer', 7: 'Vision System', 8: 'Genspect'} + + +def stage_metrology(h): + """Metrology PCs (CMM/Genspect/Keyence/Wax) are computers that CONTROL an + instrument. Classic has no separate tool row, so synthesize a measuring-tool + asset per metrology PC and a Controls relationship (PC -> tool), mirroring + what the runtime collector does. Runs after the asset hub (needs the PC's + assetid from the crosswalk).""" + controls_id = _upsert(h, '/api/assets/relationshiptypes', + {'relationshiptype': 'Controls'}, 'relationshiptype', + 'relationshiptypeid', '/api/assets/relationshiptypes') + made = linked = 0 + for m in h.source.rows('shopdb_src', + 'SELECT machineid, alias, hostname, machinenumber, businessunitid, ' + 'mapleft, maptop, pctypeid FROM machines ' + 'WHERE machinetypeid=33 AND pctypeid IN (5,6,7,8) ' + 'AND isactive=1 ' + 'AND (islocationonly IS NULL OR islocationonly=0)'): + pc_assetid = h.ids.get('asset', m['machineid']) + if not pc_assetid: + continue # PC not imported (dup-skipped etc.) + toolname = METROLOGY_PCTYPE_TOOL[m['pctypeid']] + typeid = _upsert(h, '/api/measuringtools/types', {'name': toolname}, + 'name', 'measuringtooltypeid', '/api/measuringtools/types') + pcname = (m['alias'] or m['hostname'] or m['machinenumber'] or '').strip() + status, data = h.post('/api/measuringtools', { + 'assetnumber': f"MT-{m['machineid']}", + 'name': (f"{pcname} {toolname}").strip() or toolname, + 'measuringtooltypeid': typeid, + 'businessunitid': h.ids.get('businessunit', m['businessunitid']), + 'mapx': m['mapleft'], 'mapy': m['maptop']}) + if status not in (200, 201): + continue + tool_assetid = _id_of(data, 'assetid') + made += 1 + if tool_assetid and controls_id: + h.ids.put('metrologytool', m['machineid'], tool_assetid) + st, _ = h.post('/api/assets/relationships', { + 'sourceassetid': pc_assetid, 'targetassetid': tool_assetid, + 'relationshiptypeid': controls_id}) + if st in (200, 201): + linked += 1 + return {'measuringtools_synth': made, 'controls_links': linked} + + +def stage_communications(h): + """Fold each asset's primary IP from the source communications table. There + is no bulk-communications endpoint, so this is one of the plan's documented + direct-ORM gaps (the API only takes a primary IP on asset create/update).""" + from shopdb.core.models import Communication, CommunicationType + from shopdb.extensions import db + ip_type = CommunicationType.query.filter_by(comtype='IP').first() + rows = h.source.rows('shopdb_src', + 'SELECT machineid, address FROM communications ' + 'WHERE comstypeid=1 AND isprimary=1') + added = 0 + for r in rows: + assetid = h.ids.get('asset', r['machineid']) + ip = (r['address'] or '').strip() + if not (assetid and ip and ip_type): + continue + if Communication.query.filter_by(assetid=assetid, isprimary=True).first(): + continue + db.session.add(Communication(assetid=assetid, comtypeid=ip_type.comtypeid, + ipaddress=ip, isprimary=True)) + added += 1 + db.session.commit() + return {'primary_ips': added} + + +def stage_applications(h): + """Support teams + applications catalog + versions + installs. Skips + inactive rows (decision); dedup of colliding app names is automatic via the + unique-appname 409-resolve.""" + from plugins.computers.models import Computer + counts = {} + + for t in h.source.rows('shopdb_src', + 'SELECT supporteamid, teamname, teamurl FROM supportteams WHERE isactive=1'): + name = (t['teamname'] or '').strip() + if not name: + continue + newid = _upsert(h, '/api/supportteams', {'teamname': name, 'teamurl': t['teamurl']}, + 'teamname', 'supportteamid', '/api/supportteams') + if newid: + h.ids.put('supportteam', t['supporteamid'], newid) + counts['supportteams'] = h.ids.count('supportteam') + + for a in h.source.rows('shopdb_src', + 'SELECT appid, appname, appdescription, supportteamid, ' + 'isinstallable, applicationnotes, installpath, applicationlink, ' + 'documentationpath FROM applications WHERE isactive=1'): + name = (a['appname'] or '').strip() + if not name: + continue + payload = {'appname': name, 'appdescription': a['appdescription'], + 'supportteamid': h.ids.get('supportteam', a['supportteamid']), + 'isinstallable': bool(a['isinstallable']), + 'applicationnotes': a['applicationnotes'], 'installpath': a['installpath'], + 'applicationlink': a['applicationlink'], + 'documentationpath': a['documentationpath']} + newid = _upsert(h, '/api/applications', payload, 'appname', 'appid', + '/api/applications') + if newid: + h.ids.put('app', a['appid'], newid) + counts['applications'] = h.ids.count('app') + + for v in h.source.rows('shopdb_src', + 'SELECT appversionid, appid, version, releasedate, notes, dateadded ' + 'FROM appversions WHERE isactive=1'): + appid = h.ids.get('app', v['appid']) + ver = (v['version'] or '').strip() + if not (appid and ver): + continue + status, data = h.post(f'/api/applications/{appid}/versions', + {'version': ver, 'releasedate': str(v['releasedate']) if v['releasedate'] else None, + 'notes': v['notes'], 'dateadded': str(v['dateadded']) if v['dateadded'] else None}) + if status in (200, 201): + h.ids.put('appversion', v['appversionid'], _id_of(data, 'appversionid')) + counts['appversions'] = h.ids.count('appversion') + + installed = 0 + for i in h.source.rows('shopdb_src', + 'SELECT appid, appversionid, machineid FROM installedapps WHERE isactive=1'): + assetid = h.ids.get('asset', i['machineid']) + appid = h.ids.get('app', i['appid']) + if not (assetid and appid): + continue + computer = Computer.query.filter_by(assetid=assetid).first() + if not computer: + continue + payload = {'appid': appid} + vid = h.ids.get('appversion', i['appversionid']) + if vid: + payload['appversionid'] = vid + status, _ = h.post(f'/api/computers/{computer.computerid}/apps', payload) + if status in (200, 201): + installed += 1 + counts['installs'] = installed + return counts + + +def stage_warranties(h): + """Warranties -> POST /api/warranty, linked to the asset via the crosswalk. + The source has no vendor; hardcode Dell (decision).""" + linked = 0 + for w in h.source.rows('shopdb_src', + 'SELECT machineid, warrantyname, enddate, servicelevel, dateadded ' + 'FROM warranties'): + assetid = h.ids.get('asset', w['machineid']) + if not assetid: + continue + payload = {'vendor': 'Dell', 'provider': 'manual', + 'servicelevel': (w['servicelevel'] or '').strip() or None, + 'enddate': str(w['enddate']) if w['enddate'] else None, + 'assetids': [assetid], + 'dateadded': str(w['dateadded']) if w['dateadded'] else None} + status, _ = h.post('/api/warranty', payload) + if status in (200, 201): + linked += 1 + return {'warranties': linked} + + +def stage_notifications(h): + """Notification types + notifications.""" + counts = {} + for t in h.source.rows('shopdb_src', + 'SELECT notificationtypeid, typename, typecolor FROM notificationtypes WHERE isactive=1'): + name = (t['typename'] or '').strip() + if not name: + continue + newid = _upsert(h, '/api/notifications/types', + {'typename': name, 'typecolor': (t['typecolor'] or '').strip() or None}, + 'typename', 'notificationtypeid', '/api/notifications/types') + if newid: + h.ids.put('notificationtype', t['notificationtypeid'], newid) + counts['notificationtypes'] = h.ids.count('notificationtype') + + # SSO -> "First Last" from the employee source, so recognition/training + # notifications display a name, not a bare SSO (the model shows employeename + # or falls back to employeesso). + ssoname = {} + for e in h.source.rows('wjf_employees_src', + 'SELECT SSO, First_Name, Last_Name FROM employees'): + full = f"{(e['First_Name'] or '').strip()} {(e['Last_Name'] or '').strip()}".strip() + if full: + ssoname[str(e['SSO']).strip()] = full + + def _names(employeesso): + if not employeesso: + return None + parts = [ssoname.get(s.strip()) for s in str(employeesso).split(',') if s.strip()] + parts = [p for p in parts if p] + return ', '.join(parts) or None + + made = 0 + for n in h.source.rows('shopdb_src', + 'SELECT notificationtypeid, businessunitid, appid, notification, ' + 'starttime, endtime, ticketnumber, link, isshopfloor, employeesso ' + 'FROM notifications WHERE isactive=1'): + text = (n['notification'] or '').strip() + if not text: + continue + endtime = str(n['endtime']) if n['endtime'] else None + if endtime and endtime.startswith('2099'): + endtime = None + payload = {'notification': text, + 'notificationtypeid': h.ids.get('notificationtype', n['notificationtypeid']), + 'businessunitid': h.ids.get('businessunit', n['businessunitid']), + 'appid': h.ids.get('app', n['appid']), + 'starttime': str(n['starttime']) if n['starttime'] else None, + 'endtime': endtime, 'ticketnumber': n['ticketnumber'], 'link': n['link'], + 'isshopfloor': bool(n['isshopfloor']), 'employeesso': n['employeesso'], + 'employeename': _names(n['employeesso'])} + status, _ = h.post('/api/notifications', payload) + if status in (200, 201): + made += 1 + counts['notifications'] = made + return counts + + +def stage_knowledgebase(h): + """KB articles. appid resolves through the applications name map (topics + folded into applications).""" + made = 0 + for k in h.source.rows('shopdb_src', + 'SELECT shortdescription, keywords, appid, linkurl, notes ' + 'FROM knowledgebase WHERE isactive=1'): + title = (k['shortdescription'] or '').strip() + url = (k['linkurl'] or '').strip() + if not (title and url): + continue + payload = {'shortdescription': title, 'linkurl': url, + 'keywords': k['keywords'], 'notes': k['notes'], + 'appid': h.ids.get('app', k['appid'])} + status, _ = h.post('/api/knowledgebase', payload) + if status in (200, 201): + made += 1 + return {'kb_articles': made} + + +def stage_locations(h): + """The 24 islocationonly rows -> core Locations. Crosswalk machineid -> + locationid (relationships can point at them).""" + made = 0 + for m in h.source.rows('shopdb_src', + 'SELECT machineid, machinenumber, alias FROM machines ' + 'WHERE machinetypeid=1 AND islocationonly=1 AND isactive=1'): + name = (m['alias'] or m['machinenumber'] or '').strip() + if not name: + continue + newid = _upsert(h, '/api/locations', {'locationname': name}, + 'locationname', 'locationid', '/api/locations') + if newid: + h.ids.put('location', m['machineid'], newid) + made += 1 + return {'locations': made} + + +def stage_relationships(h): + """Active machinerelationships -> asset relationships via the crosswalk. + Edges whose endpoints did not become assets (locations/skipped/dups) drop. + Dedups on the (source, target, type) triple.""" + # Seed relationship types (case-insensitive collation folds Controls onto + # the seeded 'controls', etc), capturing the crosswalk. A "Controlled By" + # type is the reverse of "Controls" - flag it so edges get flipped to the + # forward direction instead of importing a redundant inverse type. + reverse_typeids = set() + controls_newid = None + for t in h.source.rows('shopdb_src', + 'SELECT relationshiptypeid, relationshiptype FROM relationshiptypes WHERE isactive=1'): + name = (t['relationshiptype'] or '').strip() + if not name: + continue + lname = name.lower() + if 'controlled by' in lname: + reverse_typeids.add(t['relationshiptypeid']) + continue # do not create an inverse type; edges map to Controls + newid = _upsert(h, '/api/assets/relationshiptypes', {'relationshiptype': name}, + 'relationshiptype', 'relationshiptypeid', '/api/assets/relationshiptypes') + if newid: + h.ids.put('relationshiptype', t['relationshiptypeid'], newid) + if lname == 'controls': + controls_newid = newid + + seen = set() + made = dropped = 0 + for r in h.source.rows('shopdb_src', + 'SELECT machineid, related_machineid, relationshiptypeid ' + 'FROM machinerelationships WHERE isactive=1'): + source = h.ids.get('asset', r['machineid']) + target = h.ids.get('asset', r['related_machineid']) + # "Controlled By" edges flip to the forward Controls direction. + if r['relationshiptypeid'] in reverse_typeids: + source, target = target, source + typeid = controls_newid + else: + typeid = h.ids.get('relationshiptype', r['relationshiptypeid']) + if not (source and target and typeid) or source == target: + dropped += 1 + continue + key = (source, target, typeid) + if key in seen: + continue + seen.add(key) + status, _ = h.post('/api/assets/relationships', + {'sourceassetid': source, 'targetassetid': target, + 'relationshiptypeid': typeid}) + if status in (200, 201): + made += 1 + return {'relationships': made, 'dropped_unresolved': dropped} + + +def stage_subnets(h): + """Subnets + VLANs. Classic cidr is the suffix only; full CIDR = + INET_NTOA(ipstart)+suffix. VLANs are lookup-or-create by number; duplicate + full CIDRs are first-wins (409 skip).""" + import socket + import struct + subtypes = {s['subnettypeid']: s['subnettype'] for s in + h.source.rows('shopdb_src', 'SELECT subnettypeid, subnettype FROM subnettypes')} + made = 0 + for s in h.source.rows('shopdb_src', + 'SELECT vlan, description, ipstart, cidr, subnettypeid FROM subnets WHERE isactive=1'): + if s['ipstart'] is None or not s['cidr']: + continue + network = socket.inet_ntoa(struct.pack('>I', s['ipstart'] & 0xffffffff)) + suffix = str(s['cidr']).strip() + cidr = network + (suffix if suffix.startswith('/') else '/' + suffix) + vlanid = None + if s['vlan']: + vlanid = _upsert(h, '/api/network/vlans', + {'vlannumber': s['vlan'], 'name': f"VLAN {s['vlan']}"}, + 'vlannumber', 'vlanid', '/api/network/vlans') + name = (s['description'] or '').strip() or cidr + payload = {'name': name, 'cidr': cidr, 'networkaddress': network, + 'vlanid': vlanid, 'subnettype': subtypes.get(s['subnettypeid'])} + status, _ = h.post('/api/network/subnets', payload) + if status in (200, 201): + made += 1 + return {'subnets': made} + + +def stage_usb(h): + """CMMC USB kiosk: devices + the check-in/out log paired into checkouts. + Requires selfhosted mode. cmmc users are directory data, not usb rows.""" + from shopdb.core.models import Setting + from shopdb.extensions import db + Setting.set('usb_directory_mode', 'selfhosted', valuetype='string', category='usb') + db.session.commit() + + devices = 0 + for d in h.source.rows('cmmc_usb_src', 'SELECT device_id, device_desc, device_owner, locker_location FROM devices'): + did = (d['device_id'] or '').strip() + if not did or did.lower() in ('test', 'test123'): + continue + status, _ = h.post('/api/usb', {'device_id': did, 'device_desc': d['device_desc'], + 'device_owner': d['device_owner'], + 'locker_location': d['locker_location']}) + if status in (200, 201, 409): + devices += 1 + + # Replay the log chronologically per device, tracking open state so we only + # check out an available device and check in an out one (the source log has + # unpaired entries - a checkin with no open checkout would 400). + events = 0 + checked_out = {} + log = h.source.rows('cmmc_usb_src', + 'SELECT device_id, badge_number, action, timestamp, locker_location ' + 'FROM checkinoutlog ORDER BY device_id, timestamp') + for e in log: + did = (e['device_id'] or '').strip() + badge = str(e['badge_number'] or '').strip() + action = (e['action'] or '').strip().lower() + if not (did and badge): + continue + when = str(e['timestamp']) if e['timestamp'] else None + if 'out' in action and not checked_out.get(did): + status, _ = h.post(f'/api/usb/{did}/checkout', + {'badge': badge, 'checkouttime': when, + 'locker_location': e['locker_location']}) + if status in (200, 201): + checked_out[did] = True + events += 1 + elif 'in' in action and checked_out.get(did): + status, _ = h.post(f'/api/usb/{did}/checkin', {'badge': badge, 'checkintime': when}) + if status in (200, 201): + checked_out[did] = False + events += 1 + return {'usb_devices': devices, 'usb_events': events} + + +def stage_verify(h): + """Row-count audit: source active count vs target count for each entity.""" + checks = [ + ('vendors', 'shopdb_src', 'SELECT COUNT(*) c FROM vendors WHERE isactive=1', 'vendors'), + ('assets', 'shopdb_src', 'SELECT COUNT(*) c FROM machines WHERE isactive=1', 'assets'), + ('applications', 'shopdb_src', 'SELECT COUNT(*) c FROM applications WHERE isactive=1', 'applications'), + ('knowledgebase', 'shopdb_src', 'SELECT COUNT(*) c FROM knowledgebase WHERE isactive=1', 'knowledgebase'), + ('employees', 'wjf_employees_src', 'SELECT COUNT(*) c FROM employees', 'directoryemployees'), + ] + from shopdb.extensions import db + from sqlalchemy import text + report = {} + for label, sdb, sql, target_table in checks: + src = h.source.rows(sdb, sql)[0]['c'] + tgt = db.session.execute(text(f'SELECT COUNT(*) FROM {target_table}')).scalar() + report[label] = f'source~{src} target={tgt}' + return report + + +STAGES = { + 'reference': stage_reference, + 'employees': stage_employees, + 'catalog': stage_catalog, + 'assets': stage_assets, + 'locations': stage_locations, + 'printers': stage_printers, + 'metrology': stage_metrology, + 'communications': stage_communications, + 'applications': stage_applications, + 'warranties': stage_warranties, + 'notifications': stage_notifications, + 'knowledgebase': stage_knowledgebase, + 'relationships': stage_relationships, + 'subnets': stage_subnets, + 'usb': stage_usb, + 'verify': stage_verify, +} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + '--stages', + default='reference,employees,catalog,assets,locations,printers,' + 'metrology,communications,applications,warranties,notifications,' + 'knowledgebase,relationships,subnets,usb,verify', + help='comma list of stages to run') + args = parser.parse_args() + + h = Harness() + print(f'== WJ import loader (target: {h.app.config["SQLALCHEMY_DATABASE_URI"].split("/")[-1]}) ==') + try: + for name in args.stages.split(','): + name = name.strip() + if name not in STAGES: + print(f' skip unknown stage: {name}') + continue + result = STAGES[name](h) + print(f' [{name}] {result}') + if h.errors: + print(f'\n {len(h.errors)} endpoint error(s):') + for path, code, _payload, data in h.errors[:10]: + print(f' {code} {path}: {data}') + finally: + h.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/test-external-plugin.sh b/scripts/test-external-plugin.sh new file mode 100755 index 00000000..aa61e99d --- /dev/null +++ b/scripts/test-external-plugin.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# +# test-external-plugin.sh +# +# Verify an out-of-tree shopdb-flask plugin against a pinned framework build. +# Stand up a throwaway copy of the framework, drop the plugin into +# plugins// as a symlink (the mechanism ADR-003 documents for sister +# sites), and run the framework contract tests plus the plugin's own tests. +# Nonzero exit means the plugin is not compatible with that framework ref. +# +# Two modes: +# +# CI / remote (default): clone the framework at FRAMEWORK_REF from +# FRAMEWORK_URL, build a fresh venv, pip install requirements. Needs +# network access to git and PyPI. +# +# Local / offline: set LOCAL_FRAMEWORK to a framework checkout on disk. +# The script exports that checkout at HEAD with `git archive` (no network) +# and reuses the checkout's existing venv, so it runs with no internet. +# +# Inputs (env var, or positional): +# PLUGIN_DIR ($1) path to the plugin directory (holds manifest.json). Required. +# FRAMEWORK_REF ($2) git ref to test against in CI mode. Default: main. +# FRAMEWORK_URL framework git URL for CI mode. +# Default: https:///ge-aerospace/shopdb-flask.git +# LOCAL_FRAMEWORK path to an existing framework checkout. Set it to run offline. +# +# Examples: +# # CI: test the plugin in the current repo against a pinned tag +# PLUGIN_DIR=. FRAMEWORK_REF=v0.5.0 scripts/test-external-plugin.sh +# +# # Offline: test a bundled plugin against this checkout, no network +# LOCAL_FRAMEWORK=. PLUGIN_DIR=plugins/warranty scripts/test-external-plugin.sh +# +# Coverage: a symlinked plugin is discovered and loaded by the plugin loader +# (loadability, manifest validity, core_version range) AND scanned by the +# import-surface contract test - the scanner resolves each plugin dir before +# walking, since bare Path.rglob does not descend symlinks. Optionally keep an +# equivalent import-surface assertion in your own tests/ so violations fail +# in your repo's CI too (see the PLUGIN-EXTERNAL-REPO wiki page). + +set -eu + +PLUGIN_DIR="${PLUGIN_DIR:-${1:-}}" +FRAMEWORK_REF="${FRAMEWORK_REF:-${2:-main}}" +FRAMEWORK_URL="${FRAMEWORK_URL:-https:///ge-aerospace/shopdb-flask.git}" +LOCAL_FRAMEWORK="${LOCAL_FRAMEWORK:-}" + +if [ -z "$PLUGIN_DIR" ]; then + echo "ERROR: PLUGIN_DIR is required (env var or first argument)." >&2 + exit 2 +fi +if [ ! -f "$PLUGIN_DIR/manifest.json" ]; then + echo "ERROR: $PLUGIN_DIR has no manifest.json - not a plugin directory." >&2 + exit 2 +fi + +# Absolute path so the symlink still resolves after we cd into the temp tree. +PLUGIN_ABS="$(cd "$PLUGIN_DIR" && pwd)" +# Plugin name: prefer the manifest name, fall back to the directory basename. +PLUGIN_NAME="$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_ABS/manifest.json" | head -n1)" +if [ -z "$PLUGIN_NAME" ]; then + PLUGIN_NAME="$(basename "$PLUGIN_ABS")" +fi + +WORKDIR="$(mktemp -d)" +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +FRAMEWORK="$WORKDIR/framework" +mkdir -p "$FRAMEWORK" + +if [ -n "$LOCAL_FRAMEWORK" ]; then + echo "==> Local mode: exporting framework from $LOCAL_FRAMEWORK (HEAD)" + LOCAL_ABS="$(cd "$LOCAL_FRAMEWORK" && pwd)" + git -C "$LOCAL_ABS" archive HEAD | tar -x -C "$FRAMEWORK" + # Reuse the checkout's venv so no pip and no network are needed. + if [ -x "$LOCAL_ABS/venv/bin/python" ]; then + PYTHON="$LOCAL_ABS/venv/bin/python" + elif [ -x "$LOCAL_ABS/.venv/bin/python" ]; then + PYTHON="$LOCAL_ABS/.venv/bin/python" + else + echo "ERROR: no venv found under $LOCAL_ABS (looked for venv/ and .venv/)." >&2 + exit 2 + fi +else + echo "==> CI mode: cloning $FRAMEWORK_URL @ $FRAMEWORK_REF" + git clone --depth 1 --branch "$FRAMEWORK_REF" "$FRAMEWORK_URL" "$FRAMEWORK" + echo "==> Creating venv and installing requirements" + python3 -m venv "$WORKDIR/venv" + PYTHON="$WORKDIR/venv/bin/python" + "$PYTHON" -m pip install --upgrade pip >/dev/null + "$PYTHON" -m pip install -r "$FRAMEWORK/requirements.txt" +fi + +echo "==> Linking plugin '$PLUGIN_NAME' into framework plugins/" +rm -rf "$FRAMEWORK/plugins/$PLUGIN_NAME" +ln -s "$PLUGIN_ABS" "$FRAMEWORK/plugins/$PLUGIN_NAME" + +# Drop any exported registry so the loader re-discovers and enables the plugin +# (the test conftest seeds a fresh registry from plugins/*/manifest.json). +rm -f "$FRAMEWORK/instance/plugins.json" + +RC=0 + +echo "==> Running framework contract tests" +( cd "$FRAMEWORK" && "$PYTHON" -m pytest tests/test_plugin_contract.py -q ) || RC=1 + +if [ -d "$PLUGIN_ABS/tests" ]; then + echo "==> Running plugin's own tests" + ( cd "$FRAMEWORK" && "$PYTHON" -m pytest "$PLUGIN_ABS/tests" -q ) || RC=1 +else + echo "==> Plugin has no tests/ directory - skipping plugin test step" +fi + +if [ "$RC" -eq 0 ]; then + echo "==> PASS: plugin '$PLUGIN_NAME' is compatible with framework ref '$FRAMEWORK_REF'" +else + echo "==> FAIL: plugin '$PLUGIN_NAME' - see output above" >&2 +fi +exit "$RC" diff --git a/shopdb/__init__.py b/shopdb/__init__.py new file mode 100644 index 00000000..2a6857a2 --- /dev/null +++ b/shopdb/__init__.py @@ -0,0 +1,260 @@ +"""Flask application factory.""" + +import os +import logging +from flask import Flask, send_from_directory + +from .config import config +from .extensions import db, jwt, init_extensions +from .plugins import plugin_manager + +# Platform contract version. See ADR-001 for the contract surface and +# ADR-002 for the bump rules. Plugins declare a compatible range in +# their manifest.json `core_version` field. Pre-1.0 (0.x) means the +# contract is still settling; sister sites should pin tight ranges. +# 0.3.0: shopdb.api expanded to the full plugin import surface (db, cache, +# model bases, core models, response + pagination helpers, employee_connection) +# so plugins no longer import internal core paths. Additive, hence minor bump. +# 0.4.0: removed the never-implemented get_searchable_fields hook (search is a +# core concern over the asset model) and wired the get_dashboard_widgets hook to +# a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction. +# 0.6.0: added the get_reports hook, consumed by GET /api/reports to merge +# plugin report cards into the Reports hub. Additive optional hook, minor bump. +# 0.7.0: added the four ADR-010 frontend-contribution hooks (get_settings_cards, +# get_asset_panels, get_map_overlays, get_asset_presentation), consumed by the +# GET /api/pluginui/* endpoints. Four additive optional hooks, one minor bump. +# 0.9.0: added the dualpath single-machine collapse helpers to shopdb.api +# (resolve_dualpath_pairs, dualpath_single_machine_enabled), consumed by the +# machines plugin list/detail to collapse dual-bay pairs. Two additive names, +# minor bump. +# 0.10.0: added the get_permissions hook so a plugin declares the RBAC +# permissions its own routes enforce, instead of core accumulating them in one +# catalog. Consumed by full_permission_catalog() (core + enabled plugins), +# which backs seeding, the role grid, and API-token scope validation. Additive +# optional hook, minor bump. +# 0.11.0: added service_token_authorized(scope) to shopdb.api so a plugin's +# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped +# managed service token without importing core token internals. Additive name +# on the import surface, minor bump. +__contract_version__ = '0.11.0' + +# Product release version (see ADR-007). The product version and the +# plugin-contract version above are distinct series with independent +# bump rules. Not part of the shopdb.api contract surface, so it is +# not re-exported there. +__version__ = '0.7.0' + + +def create_app(config_name: str = None) -> Flask: + """ + Application factory. + + Args: + config_name: Configuration name ('development', 'production', 'testing') + + Returns: + Configured Flask application + """ + if config_name is None: + config_name = os.environ.get('FLASK_ENV', 'development') + + app = Flask(__name__, instance_relative_config=True) + + config_class = config.get(config_name, config['default']) + + # Production must validate its env-driven config before boot. + if config_name == 'production' and hasattr(config_class, 'validate'): + config_class.validate() + + app.config.from_object(config_class) + + # Load instance config if exists + app.config.from_pyfile('config.py', silent=True) + + # Per-plugin collector keys (ADR-006) are dynamic env-vars + # (COLLECTOR_API_KEY_) that from_object cannot pick up because + # they are not class attributes. Copy them in explicitly so per-plugin + # credential isolation works in real deploys, not just tests. + for envname, envvalue in os.environ.items(): + if envname.startswith('COLLECTOR_API_KEY_') and envvalue: + app.config[envname] = envvalue + + # Ensure instance folder exists + os.makedirs(app.instance_path, exist_ok=True) + + # Configure logging + configure_logging(app) + + # Initialize extensions + init_extensions(app) + + # Initialize plugin manager + with app.app_context(): + plugin_manager.init_app(app, db) + + # Register core blueprints + register_blueprints(app) + + # Personal API token auth shim: recognize `Bearer shopdb_pat_...` before + # any JWT decode and mint a request-scoped JWT for the token's owner. + from .utils.apitoken_auth import install_apitoken_auth + install_apitoken_auth(app) + + # Register CLI commands + register_cli_commands(app) + + # Register error handlers + register_error_handlers(app) + + # Serve Vue frontend + register_frontend_routes(app) + + # JWT user loader (identity is a string in JWT, convert to int for DB lookup) + @jwt.user_lookup_loader + def user_lookup_callback(_jwt_header, jwt_data): + from .core.models import User + identity = jwt_data["sub"] + return db.session.get(User, int(identity)) + + return app + + +CORE_BLUEPRINT_NAMES = ( + 'auth', + 'assets', + 'modeltypes', + 'plugins', + 'vendors', + 'models', + 'businessunits', + 'locations', + 'operatingsystems', + 'dashboard', + 'dashboarddefaults', + 'applications', + 'supportteams', + 'search', + 'reports', + 'collector', + 'settings', + 'auditlogs', + 'users', + 'customfields', + 'setup', + 'pluginui', + 'apitokens', +) + + +def register_blueprints(app: Flask): + """Register core API blueprints from CORE_BLUEPRINT_NAMES. + + Each entry maps to an attribute `_bp` exported by + `shopdb.core.api` and a URL prefix `/api/`. Adding a new + core resource is one entry in CORE_BLUEPRINT_NAMES, not a 3-line + edit in this function. + """ + from .core import api as api_module + + api_prefix = '/api' + for name in CORE_BLUEPRINT_NAMES: + attr_name = f'{name}_bp' + if not hasattr(api_module, attr_name): + raise RuntimeError( + f'Core blueprint "{attr_name}" missing from shopdb.core.api. ' + f'Either add it or remove "{name}" from CORE_BLUEPRINT_NAMES.' + ) + bp = getattr(api_module, attr_name) + app.register_blueprint(bp, url_prefix=f'{api_prefix}/{name}') + + +def register_cli_commands(app: Flask): + """Register Flask CLI commands.""" + from .plugins.cli import plugin_cli + from .cli import db_cli, seed_cli, relationships_cli + + app.cli.add_command(plugin_cli) + app.cli.add_command(db_cli) + app.cli.add_command(seed_cli) + app.cli.add_command(relationships_cli) + + +def register_error_handlers(app: Flask): + """Register error handlers.""" + from .utils.responses import error_response, ErrorCodes + from .exceptions import ShopDBException + + @app.errorhandler(ShopDBException) + def handle_shopdb_exception(error): + http_codes = { + 'NOT_FOUND': 404, + 'UNAUTHORIZED': 401, + 'FORBIDDEN': 403, + 'CONFLICT': 409, + 'VALIDATION_ERROR': 400, + } + http_code = http_codes.get(error.code, 400) + return error_response( + error.code, + error.message, + details=error.details, + http_code=http_code + ) + + @app.errorhandler(404) + def not_found_error(error): + return error_response( + ErrorCodes.NOT_FOUND, + 'Resource not found', + http_code=404 + ) + + @app.errorhandler(500) + def internal_error(error): + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'An internal error occurred', + http_code=500 + ) + + @app.errorhandler(401) + def unauthorized_error(error): + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Authentication required', + http_code=401 + ) + + +def register_frontend_routes(app: Flask): + """Serve Vue frontend static files.""" + frontend_dist = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'frontend', 'dist') + + @app.route('/', defaults={'path': ''}) + @app.route('/') + def serve_frontend(path): + # Don't serve API routes as frontend + if path.startswith('api/'): + from .utils.responses import error_response, ErrorCodes + return error_response(ErrorCodes.NOT_FOUND, 'API endpoint not found', http_code=404) + + # Try to serve a static asset directly. send_from_directory handles + # the safe-join + 404 itself; no explicit existence probe needed + # (the probe was a path-traversal risk surface). + if path: + try: + return send_from_directory(frontend_dist, path) + except Exception: + pass + + return send_from_directory(frontend_dist, 'index.html') + + +def configure_logging(app: Flask): + """Configure application logging.""" + log_level = app.config.get('LOG_LEVEL', 'INFO') + + logging.basicConfig( + level=getattr(logging, log_level), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py new file mode 100644 index 00000000..d4b2ac95 --- /dev/null +++ b/shopdb/api/__init__.py @@ -0,0 +1,271 @@ +"""Public API namespace exposed to plugins. + +Plugin authors at sister sites import from this module. The contract is +locked in ADR-001 and versioned per ADR-002. Helpers added here become +part of the platform contract; bumps follow ADR-002 rules. + +Currently exposed: +- audit_log: record an audit log entry with consistent schema +- resolve_asset_position: compute an asset's resolved map position + +Setting helpers are exposed via BasePlugin instance methods +(plugin.get_setting, plugin.set_setting), not from this namespace. +""" + +from typing import Any, Dict, Optional + +# -- Plugin contract surface (ADR-001, versioned per ADR-002) ---------------- +# Everything a plugin is allowed to import from the core lives here. Plugins +# import these from `shopdb.api`, never from internal paths like +# `shopdb.core.models.*` or `shopdb.extensions`. The contract test +# (tests/test_plugin_contract.py) enforces this. Adding a name here is an +# additive (minor) contract change; removing one is breaking (major). + +# Infrastructure +from shopdb.extensions import db, cache + +# Model base classes for declaring plugin tables +from shopdb.core.models.base import BaseModel, AuditMixin + +# Core domain models plugins legitimately reference (the asset contract) +from shopdb.core.models import ( + Asset, + AssetType, + AssetStatus, + Vendor, + Model, + Communication, + CommunicationType, + Location, + Setting, + AuditLog, + Application, + AppVersion, + OperatingSystem, + AssetRelationship, + RelationshipType, +) + +# Response + pagination helpers for plugin API blueprints +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes, +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +# Authorization decorators for gating plugin write routes +from shopdb.utils.authz import require_permission, require_role + +# Service-token authorization for unattended plugin endpoints (collector, +# GE-Enforce fetch, ...): checks a scoped managed token without exposing token +# internals. +from shopdb.utils.apitoken_auth import service_token_authorized + +# Import-mode helpers: preserve legacy timestamps during a bulk data import +from shopdb.utils.import_mode import ( + apply_import_timestamps, + import_mode_active, + parse_import_datetime, +) + +# Dualpath single-machine collapse (a dual-bay pair is one physical machine) +from shopdb.core.services.dualpath import ( + resolve_dualpath_pairs, + dualpath_single_machine_enabled, +) + +# Legacy employee directory lookup (read-only) used by notifications +from shopdb.utils.employee_db import employee_connection + +# CMMC USB check-in/out database (read-write) used by the usb plugin +from shopdb.utils.cmmc_usb_db import cmmc_usb_connection + + +def audit_log( + action: str, + entitytype: str, + entityid: int = None, + entityname: str = None, + changes: Dict = None, + details: Dict = None, +) -> AuditLog: + """Record an audit log entry with the framework's standard schema. + + Plugins call this to record state changes on their own assets in a + way that is consistent with core auditing. The function delegates to + AuditLog.log() which already captures the current user, IP address, + and user agent from the Flask request context. + + Args: + action: Action verb in past tense ('created', 'updated', 'deleted') + entitytype: Class name of the entity affected ('Computer', 'Printer') + entityid: Primary key of the entity + entityname: Human-readable identifier (hostname, asset number) + changes: Dict with 'before' and 'after' snapshots for updates + details: Arbitrary additional context + + Returns: + The created AuditLog instance, already committed to the DB. + """ + return AuditLog.log( + action=action, + entitytype=entitytype, + entityid=entityid, + entityname=entityname, + changes=changes, + details=details, + ) + + +# Cap how deep the relationship-walk traverses before giving up. ADR-001 +# specifies a max walk depth of 3 to bound the work per request, with a +# visited-set guarding against cycles. Past this depth the walk treats the +# next hop as if it had no position to contribute. +_POSITION_WALK_MAX_DEPTH = 3 + +# Relationship type names whose edges are eligible for the inheritance walk +# when inheritsposition is true on the edge. Ordered by priority per +# ADR-001 ("partof first, then controls"). Edges of other types are never +# followed even if inheritsposition is true. +_INHERITABLE_TYPES = ('partof', 'controls') + + +def _walk_related_for_position(asset, visited, depth): + """Recursive helper for resolve_asset_position relationship walk. Returns + a (mapx, mapy) tuple from the first related asset whose position + resolves, or None. Visited tracks assetids already explored to break + cycles.""" + if depth >= _POSITION_WALK_MAX_DEPTH: + return None + aid = getattr(asset, 'assetid', None) + if aid is None or aid in visited: + return None + visited.add(aid) + + edges = [] + for rel in list(getattr(asset, 'outgoing_relationships', []) or []): + edges.append((rel, getattr(rel, 'targetasset', None))) + for rel in list(getattr(asset, 'incoming_relationships', []) or []): + edges.append((rel, getattr(rel, 'sourceasset', None))) + + def _priority(edge): + rel = edge[0] + rtype = getattr(rel, 'relationshiptype', None) + type_name = getattr(rtype, 'relationshiptype', '') if rtype else '' + try: + return _INHERITABLE_TYPES.index(type_name) + except ValueError: + return len(_INHERITABLE_TYPES) + + edges.sort(key=_priority) + + for rel, neighbor in edges: + if neighbor is None: + continue + if not getattr(rel, 'inheritsposition', False): + continue + if not getattr(rel, 'isactive', True): + continue + rtype = getattr(rel, 'relationshiptype', None) + type_name = getattr(rtype, 'relationshiptype', '') if rtype else '' + if type_name not in _INHERITABLE_TYPES: + continue + + n_mapx = getattr(neighbor, 'mapx', None) + n_mapy = getattr(neighbor, 'mapy', None) + if n_mapx is not None and n_mapy is not None: + return (n_mapx, n_mapy) + + recursed = _walk_related_for_position(neighbor, visited, depth + 1) + if recursed is not None: + return recursed + return None + + +def resolve_asset_position(asset) -> Optional[Dict[str, Any]]: + """Compute the resolved map position for an asset. + + Per ADR-001, position resolution follows this priority chain: + 1. Asset-specific override (asset.mapx, asset.mapy) + 2. Walk relationships where inheritsposition is true on edges of type + partof or controls (partof first), depth-limited and cycle-safe + 3. Asset's location coords (asset.location.mapx, .mapy) + 4. None (asset is unplaced, rendered in a tray) + + Returns a dict {'mapx', 'mapy', 'positionsource'} where positionsource + is one of 'self', 'related', 'location'. Returns None when no priority + yields coordinates. + """ + mapx = getattr(asset, 'mapx', None) + mapy = getattr(asset, 'mapy', None) + if mapx is not None and mapy is not None: + return {'mapx': mapx, 'mapy': mapy, 'positionsource': 'self'} + + related = _walk_related_for_position(asset, set(), 0) + if related is not None: + return {'mapx': related[0], 'mapy': related[1], 'positionsource': 'related'} + + location = getattr(asset, 'location', None) + if location is not None: + loc_mapx = getattr(location, 'mapx', None) + loc_mapy = getattr(location, 'mapy', None) + if loc_mapx is not None and loc_mapy is not None: + return { + 'mapx': loc_mapx, + 'mapy': loc_mapy, + 'positionsource': 'location', + } + + return None + + +__all__ = [ + # Helpers + 'audit_log', + 'resolve_asset_position', + 'resolve_dualpath_pairs', + 'dualpath_single_machine_enabled', + # Infrastructure + 'db', + 'cache', + # Model bases + 'BaseModel', + 'AuditMixin', + # Core models + 'Asset', + 'AssetType', + 'AssetStatus', + 'Vendor', + 'Model', + 'Communication', + 'CommunicationType', + 'Location', + 'Setting', + 'AuditLog', + 'Application', + 'AppVersion', + 'OperatingSystem', + 'AssetRelationship', + 'RelationshipType', + # Response + pagination helpers + 'success_response', + 'error_response', + 'paginated_response', + 'ErrorCodes', + 'get_pagination_params', + 'paginate_query', + # Authorization decorators + 'require_permission', + 'require_role', + 'service_token_authorized', + # Import-mode helpers + 'apply_import_timestamps', + 'import_mode_active', + 'parse_import_datetime', + # Legacy employee directory + 'employee_connection', + # CMMC USB check-in/out database + 'cmmc_usb_connection', +] diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py new file mode 100644 index 00000000..3d991339 --- /dev/null +++ b/shopdb/cli/__init__.py @@ -0,0 +1,513 @@ +"""Flask CLI commands.""" + +import click +from flask.cli import with_appcontext + + +@click.group('db-utils') +def db_cli(): + """Database utility commands.""" + pass + + +@db_cli.command('create-all') +@with_appcontext +def create_all(): + """Create all database tables.""" + from shopdb.extensions import db + + db.create_all() + click.echo(click.style("All tables created.", fg='green')) + + +@db_cli.command('preflight') +@with_appcontext +def preflight(): + """Check install prerequisites before `flask db upgrade`. + + Reports what must be fixed/installed (Python, required env, DB connectivity, + and the MySQL 5.6 utf8mb4 index flags). Exits non-zero if anything blocks. + """ + import sys + import os + from sqlalchemy import text + from flask import current_app + from shopdb.extensions import db + + failures = [] + warnings = [] + + def ok(msg): + click.echo(click.style(' PASS ', fg='green') + msg) + + def fail(msg, fix): + failures.append((msg, fix)) + click.echo(click.style(' FAIL ', fg='red') + msg) + click.echo(click.style(' fix: ', fg='red') + fix) + + def warn(msg, fix): + warnings.append((msg, fix)) + click.echo(click.style(' WARN ', fg='yellow') + msg) + click.echo(click.style(' ', fg='yellow') + fix) + + click.echo(click.style('ShopDB preflight', bold=True)) + + # Python + py = sys.version_info + if py >= (3, 9): + ok(f'Python {py.major}.{py.minor}.{py.micro}') + else: + fail(f'Python {py.major}.{py.minor} is too old', + 'Install Python 3.9 or newer.') + + # Required config + for key in ('SECRET_KEY', 'JWT_SECRET_KEY', 'DATABASE_URL'): + value = current_app.config.get(key) or os.environ.get(key) + if value and 'change' not in str(value).lower() and 'dev-' not in str(value).lower(): + ok(f'{key} is set') + else: + fail(f'{key} is missing or a dev default', + f'Set {key} in .env (64+ random chars for the secrets).') + + # DB connectivity + MySQL index prerequisites + try: + version = db.session.execute(text('SELECT VERSION()')).scalar() + ok(f'Database reachable (MySQL {version})') + except Exception as exc: + fail('Cannot connect to the database', f'Check DATABASE_URL / server. ({exc})') + version = '' + + if version and version.startswith('5.6'): + variables = {} + for name in ('innodb_large_prefix', 'innodb_file_format', 'innodb_file_per_table'): + try: + row = db.session.execute(text(f"SHOW VARIABLES LIKE '{name}'")).fetchone() + variables[name] = row[1] if row else None + except Exception: + variables[name] = None + needed = {'innodb_large_prefix': 'ON', 'innodb_file_format': 'Barracuda', + 'innodb_file_per_table': 'ON'} + bad = [n for n, want in needed.items() if str(variables.get(n)).lower() != want.lower()] + if bad: + fail('MySQL 5.6 index flags not set: ' + ', '.join(bad), + 'Add to my.cnf [mysqld]: innodb_file_per_table=1, ' + 'innodb_file_format=Barracuda, innodb_large_prefix=1 (then restart). ' + 'Otherwise `flask db upgrade` fails with error 1071.') + else: + ok('MySQL 5.6 index flags OK (Barracuda + large_prefix)') + elif version: + ok('MySQL 5.7+/8.0 - no extra index flags needed') + + click.echo('') + if failures: + click.echo(click.style(f'{len(failures)} blocker(s). Fix them before installing.', fg='red', bold=True)) + raise SystemExit(1) + click.echo(click.style('All prerequisites met.' + (f' ({len(warnings)} warning(s))' if warnings else ''), fg='green', bold=True)) + + +@db_cli.command('drop-all') +@click.confirmation_option(prompt='This will delete ALL data. Are you sure?') +@with_appcontext +def drop_all(): + """Drop all database tables.""" + from shopdb.extensions import db + + db.drop_all() + click.echo(click.style("All tables dropped.", fg='yellow')) + + +@click.group('seed') +def seed_cli(): + """Database seeding commands.""" + pass + + +@seed_cli.command('reference-data') +@with_appcontext +def seed_reference_data(): + """Seed reference data (model types, statuses, etc.).""" + from shopdb.extensions import db + from shopdb.core.models import (ModelType, OperatingSystem, AssetStatus, + LocationType, CommunicationType) + from shopdb.core.models.relationship import RelationshipType + + # Model types (type the vendor models catalog) + model_types = [ + {'modeltype': 'CNC Mill', 'category': 'Equipment', 'description': 'CNC Milling Machine'}, + {'modeltype': 'CNC Lathe', 'category': 'Equipment', 'description': 'CNC Lathe'}, + {'modeltype': 'CMM', 'category': 'Equipment', 'description': 'Coordinate Measuring Machine'}, + {'modeltype': 'EDM', 'category': 'Equipment', 'description': 'Electrical Discharge Machine'}, + {'modeltype': 'Grinder', 'category': 'Equipment', 'description': 'Grinding Machine'}, + {'modeltype': 'Inspection Station', 'category': 'Equipment', 'description': 'Inspection Station'}, + {'modeltype': 'Desktop PC', 'category': 'PC', 'description': 'Desktop Computer'}, + {'modeltype': 'Laptop', 'category': 'PC', 'description': 'Laptop Computer'}, + {'modeltype': 'Shopfloor PC', 'category': 'PC', 'description': 'Shopfloor Computer'}, + {'modeltype': 'Server', 'category': 'Network', 'description': 'Server'}, + {'modeltype': 'Switch', 'category': 'Network', 'description': 'Network Switch'}, + {'modeltype': 'Access Point', 'category': 'Network', 'description': 'Wireless Access Point'}, + ] + + for mt_data in model_types: + existing = ModelType.query.filter_by(modeltype=mt_data['modeltype']).first() + if not existing: + mt = ModelType(**mt_data) + db.session.add(mt) + + # Asset statuses (canonical set - the asset model is the contract) + asset_statuses = [ + {'status': 'In Use', 'description': 'Currently in use', 'color': '#28a745'}, + {'status': 'Inventory', 'description': 'In inventory', 'color': '#17a2b8'}, + {'status': 'In Repair', 'description': 'Being repaired', 'color': '#ffc107'}, + {'status': 'Retired', 'description': 'No longer in use', 'color': '#6c757d'}, + {'status': 'Returned', 'description': 'Returned to vendor or owner', 'color': '#fd7e14'}, + {'status': 'Warrantied', 'description': 'Under warranty service', 'color': '#20c997'}, + {'status': 'Lost', 'description': 'Lost or missing', 'color': '#dc3545'}, + ] + + for s_data in asset_statuses: + existing = AssetStatus.query.filter_by(status=s_data['status']).first() + if not existing: + db.session.add(AssetStatus(isactive=True, **s_data)) + elif existing.isactive is not True: + existing.isactive = True + + # Location types (ADR-001) + location_types = ['section', 'cell', 'subcell', 'operation', 'meetingroom', + 'lab', 'office', 'storage', 'hallway', 'networkcloset', + 'building'] + for lt in location_types: + if not LocationType.query.filter_by(locationtype=lt).first(): + db.session.add(LocationType(locationtype=lt, isactive=True)) + + # Communication types (how an asset is reached / its interfaces). The IP + # type is what the printer + network create routes attach an ipaddress to, + # so it must exist before any asset import. + comm_types = [ + ('IP', 'IP address / network reachable'), + ('Serial', 'Serial (RS-232) connection'), + ('Network_Interface', 'Physical network interface (MAC/port)'), + ('USB', 'USB connection'), + ('Parallel', 'Parallel port connection'), + ('VNC', 'VNC remote access'), + ('FTP', 'FTP file transfer'), + ('DNC', 'Direct numerical control link'), + ] + for comtype, description in comm_types: + if not CommunicationType.query.filter_by(comtype=comtype).first(): + db.session.add(CommunicationType(comtype=comtype, description=description)) + + # Operating systems + os_list = [ + {'osname': 'Windows 10', 'osversion': '10.0'}, + {'osname': 'Windows 11', 'osversion': '11.0'}, + {'osname': 'Windows Server 2019', 'osversion': '2019'}, + {'osname': 'Windows Server 2022', 'osversion': '2022'}, + {'osname': 'Linux', 'osversion': 'Various'}, + ] + + for os_data in os_list: + existing = OperatingSystem.query.filter_by(osname=os_data['osname']).first() + if not existing: + os_obj = OperatingSystem(**os_data) + db.session.add(os_obj) + + # Connection types (pre-1.0 legacy; kept for backward compat with + # existing relationship rows. New ADR-001 code reasons about the three + # canonical types below via free-text label.) + # all symmetric physical/network links -> isdirectional=False so the + # relationships card shows one direction-blind "connected" entry per peer. + connection_types = [ + {'relationshiptype': 'Serial Cable', 'description': 'RS-232 or similar serial connection', 'isdirectional': False}, + {'relationshiptype': 'Direct Ethernet', 'description': 'Direct network cable (airgapped)', 'isdirectional': False}, + {'relationshiptype': 'USB', 'description': 'USB connection', 'isdirectional': False}, + {'relationshiptype': 'WiFi', 'description': 'Wireless network connection', 'isdirectional': False}, + {'relationshiptype': 'Dualpath', 'description': 'Redundant/failover network path', 'isdirectional': False}, + ] + + for ct_data in connection_types: + existing = RelationshipType.query.filter_by(relationshiptype=ct_data['relationshiptype']).first() + if not existing: + ct = RelationshipType(**ct_data) + db.session.add(ct) + + # ADR-001 canonical relationship types. Created first, then their + # propagation rails are seeded as relationshiptypepropagations rows (M:N). + # All idempotent. + # + # MySQL collation is case-insensitive by default, which would let a + # legacy capitalized row (e.g. "Controls") match the lowercase + # "controls" check and skip the insert. Force binary comparison via + # collate so the three ADR-001 types stay distinct from any legacy + # rows with the same spelling but different case. + from sqlalchemy import func, literal + def _lookup_binary(name): + dialect = db.engine.dialect.name + if dialect == 'mysql': + return RelationshipType.query.filter( + func.binary(RelationshipType.relationshiptype) == literal(name) + ).first() + return RelationshipType.query.filter_by(relationshiptype=name).first() + + adr_types = [ + {'relationshiptype': 'partof', 'description': 'Composition / sub-assembly (ADR-001)', 'isdirectional': True}, + {'relationshiptype': 'controls', 'description': 'Operational authority over another asset (ADR-001)', 'isdirectional': True}, + {'relationshiptype': 'connectedto', 'description': 'Network or data link without authority (ADR-001)', 'isdirectional': False}, + ] + for at in adr_types: + if not _lookup_binary(at['relationshiptype']): + db.session.add(RelationshipType(**at)) + db.session.flush() + + # Seed `controls` propagation rails as M:N rows. controls -> partof + # (declared; directional rail, not consumed yet) and controls -> Dualpath + # (consumed; a dual-bay pair shares one controller so both bays carry + # controls). Idempotent, resolved by name, skipped if a type is missing. + from shopdb.core.models.relationship import RelationshipTypePropagation + + def _seed_propagation(sourcename, throughname): + source = _lookup_binary(sourcename) + through = _lookup_binary(throughname) + if not source or not through: + return + exists = RelationshipTypePropagation.query.filter_by( + relationshiptypeid=source.relationshiptypeid, + throughtypeid=through.relationshiptypeid, + ).first() + if not exists: + db.session.add(RelationshipTypePropagation( + relationshiptypeid=source.relationshiptypeid, + throughtypeid=through.relationshiptypeid, + )) + + _seed_propagation('controls', 'partof') + _seed_propagation('controls', 'Dualpath') + + # Default-printer link: a PC asset -> its default printer asset. Read by the + # printer-installer endpoint (parity with classic apipcdefaultprinter.asp). + # Attribute-style edge, not a position rail, so no propagation. + if not _lookup_binary('defaultprinter'): + db.session.add(RelationshipType( + relationshiptype='defaultprinter', + description='PC to its default printer (installer preselect, ADR-001)' + )) + + db.session.commit() + click.echo(click.style("Reference data seeded.", fg='green')) + + +@seed_cli.command('test-user') +@with_appcontext +def seed_test_user(): + """Create a test admin user (admin / admin123). DEV ONLY.""" + from flask import current_app + from shopdb.extensions import db + from shopdb.core.models import User, Role + from werkzeug.security import generate_password_hash + + # Refuse in production: this seeds a well-known credential. Sites bootstrap + # a real admin with `flask seed admin` (generated password) or the wizard. + if not (current_app.config.get('DEBUG') or current_app.config.get('TESTING')): + raise click.ClickException( + 'seed test-user is dev-only (creates admin/admin123). ' + 'Use `flask seed admin` to create a production admin.') + + # Create admin role if not exists + admin_role = Role.query.filter_by(rolename='admin').first() + if not admin_role: + admin_role = Role(rolename='admin', description='Administrator') + db.session.add(admin_role) + + # Create test user + test_user = User.query.filter_by(username='admin').first() + if not test_user: + test_user = User( + username='admin', + email='admin@localhost', + passwordhash=generate_password_hash('admin123'), + isactive=True + ) + test_user.roles.append(admin_role) + db.session.add(test_user) + db.session.commit() + click.echo(click.style("Test user created: admin / admin123", fg='green')) + else: + click.echo(click.style("Test user already exists", fg='yellow')) + + +@seed_cli.command('admin') +@click.option('--username', required=True, help='Admin login username') +@click.option('--email', required=True, help='Admin email address') +@click.option('--password', default=None, + help='Admin password. Omit to generate a strong one and print it once.') +@with_appcontext +def seed_admin(username, email, password): + """Create the first admin user for a new site. + + Password is generated and printed ONCE if not supplied. Store it safely. + """ + import secrets + from shopdb.extensions import db + from shopdb.core.models import User, Role + from werkzeug.security import generate_password_hash + + if User.query.filter_by(username=username).first(): + click.echo(click.style(f'User "{username}" already exists.', fg='yellow')) + return + + admin_role = Role.query.filter_by(rolename='admin').first() + if not admin_role: + admin_role = Role(rolename='admin', description='Administrator') + db.session.add(admin_role) + + generated = password is None + if generated: + password = secrets.token_urlsafe(12) + + user = User(username=username, email=email, + passwordhash=generate_password_hash(password), isactive=True) + user.roles.append(admin_role) + db.session.add(user) + db.session.commit() + + click.echo(click.style(f'Admin "{username}" created.', fg='green')) + if generated: + click.echo(click.style('=' * 52, fg='cyan')) + click.echo(click.style(f' Password: {password}', fg='cyan', bold=True)) + click.echo(click.style(' Store this now - it will not be shown again.', fg='cyan')) + click.echo(click.style('=' * 52, fg='cyan')) + + +@click.group('relationships') +def relationships_cli(): + """Asset-relationship maintenance commands.""" + pass + + +@relationships_cli.command('propagate') +@with_appcontext +def propagate_relationships(): + """Backfill propagated relationship rows across symmetric rails. + + Scans every existing relationship of a type that propagates through a + symmetric through-type (e.g. controls through Dualpath) and creates the + missing fanned-out rows. Idempotent. Also serves the legacy-import flow: + the import creates controls links on primary bays, this fans them out to + the Dualpath partner bays. + """ + from shopdb.extensions import db + from shopdb.core.models.relationship import RelationshipType, AssetRelationship + from shopdb.core.api.assets import propagate_relationship + + # types that actually propagate through at least one symmetric through-type + propagating_ids = [ + t.relationshiptypeid for t in RelationshipType.query.all() + if any(not through.isdirectional for through in t.propagatesthrough) + ] + + total = 0 + if propagating_ids: + rels = AssetRelationship.query.filter( + AssetRelationship.relationshiptypeid.in_(propagating_ids), + AssetRelationship.isactive == True, + ).all() + for rel in rels: + total += len(propagate_relationship(rel)) + db.session.commit() + + click.echo(click.style(f"Propagated {total} relationship row(s).", fg='green')) + + +@relationships_cli.command('fix-controls-direction') +@with_appcontext +def fix_controls_direction(): + """Flip reversed legacy controls rows to PC -> machine. + + Legacy import stores controls as machine -> PC, which reads as the machine + having authority over the PC. In reality the PC is the controller (it + sends programs to the machine and receives logs), so per ADR-001 the PC + must be the source. Flips every active controls row whose source is a + machine asset and target is a computer asset. If the flipped row already + exists, the reversed duplicate is deactivated instead. Idempotent. + """ + from sqlalchemy.orm import aliased + from shopdb.extensions import db + from shopdb.core.models import Asset, AssetType + from shopdb.core.models.relationship import RelationshipType, AssetRelationship + + controls = RelationshipType.query.filter_by(relationshiptype='controls').first() + if not controls: + click.echo(click.style("No 'controls' relationship type; nothing to do.", fg='yellow')) + return + + sourceasset = aliased(Asset) + targetasset = aliased(Asset) + sourcetype = aliased(AssetType) + targettype = aliased(AssetType) + + reversed_rows = ( + AssetRelationship.query + .join(sourceasset, AssetRelationship.sourceassetid == sourceasset.assetid) + .join(targetasset, AssetRelationship.targetassetid == targetasset.assetid) + .join(sourcetype, sourceasset.assettypeid == sourcetype.assettypeid) + .join(targettype, targetasset.assettypeid == targettype.assettypeid) + .filter( + AssetRelationship.relationshiptypeid == controls.relationshiptypeid, + AssetRelationship.isactive == True, + sourcetype.assettype == 'machine', + targettype.assettype == 'computer', + ) + .all() + ) + + flipped = 0 + deactivated = 0 + for row in reversed_rows: + duplicate = AssetRelationship.query.filter_by( + sourceassetid=row.targetassetid, + targetassetid=row.sourceassetid, + relationshiptypeid=controls.relationshiptypeid, + ).first() + if duplicate: + row.isactive = False # flipped row already exists, retire this one + deactivated += 1 + else: + row.sourceassetid, row.targetassetid = row.targetassetid, row.sourceassetid + flipped += 1 + db.session.commit() + + click.echo(click.style( + f"Flipped {flipped} controls row(s) to PC -> machine" + f" ({deactivated} reversed duplicate(s) deactivated).", fg='green')) + + +@seed_cli.command('permissions') +@with_appcontext +def seed_permissions(): + """Seed predefined permissions.""" + from shopdb.extensions import db + from shopdb.core.models import Permission + + created = Permission.seed() + db.session.commit() + click.echo(click.style(f"{created} permissions created.", fg='green')) + + +@seed_cli.command('settings') +@with_appcontext +def seed_settings(): + """Seed default system settings.""" + from shopdb.extensions import db + from shopdb.core.models import Setting + from shopdb.core.api.settings import build_default_settings + + defaults = build_default_settings() + + created = 0 + for d in defaults: + if not Setting.query.filter_by(key=d['key']).first(): + setting = Setting(**d) + db.session.add(setting) + created += 1 + + db.session.commit() + click.echo(click.style(f"{created} default settings created.", fg='green')) diff --git a/shopdb/config.py b/shopdb/config.py new file mode 100644 index 00000000..00bdc042 --- /dev/null +++ b/shopdb/config.py @@ -0,0 +1,186 @@ +"""Flask application configuration.""" + +import os +from datetime import timedelta + +from sqlalchemy.pool import StaticPool + + +class ConfigError(Exception): + """Raised when required configuration is missing or unsafe.""" + + +def _required_env(varname): + """Read an env var; raise ConfigError if missing or empty.""" + value = os.environ.get(varname) + if not value: + raise ConfigError( + f'{varname} is required in production. Set it in the environment ' + f'before starting the app. Insecure defaults are not permitted in ' + f'ProductionConfig.' + ) + return value + + +class Config: + """Base configuration.""" + + SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production') + + SQLALCHEMY_DATABASE_URI = os.environ.get( + 'DATABASE_URL', + 'mysql+pymysql://root:password@localhost:3306/shopdb_flask', + ) + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ENGINE_OPTIONS = { + 'pool_pre_ping': True, + 'pool_recycle': 300, + # Pin the client connection to utf8mb4 so reads/writes match the + # utf8mb4 schema regardless of the server default charset. TestingConfig + # (SQLite) replaces this whole dict, so the charset arg never reaches it. + 'connect_args': {'charset': 'utf8mb4'}, + } + + JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production') + JWT_ACCESS_TOKEN_EXPIRES = timedelta( + seconds=int(os.environ.get('JWT_ACCESS_TOKEN_EXPIRES', 3600)) + ) + JWT_REFRESH_TOKEN_EXPIRES = timedelta( + seconds=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 2592000)) + ) + + CORS_ORIGINS = [ + origin.strip() + for origin in os.environ.get('CORS_ORIGINS', 'http://localhost:5173').split(',') + if origin.strip() + ] + + LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO') + + # API key for the unattended PowerShell collector scripts + COLLECTOR_API_KEY = os.environ.get('COLLECTOR_API_KEY', '') + + ZABBIX_ENABLED = os.environ.get('ZABBIX_ENABLED', 'false').lower() == 'true' + ZABBIX_URL = os.environ.get('ZABBIX_URL', '') + ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '') + + # Read-only HR/employee directory database (separate from the app DB). + # Credentials come from the environment; never hardcode them in source. + # No safe default for the password: unset means empty, the connection + # fails loud rather than silently trying a guessed credential. + EMPLOYEE_DB_HOST = os.environ.get('EMPLOYEE_DB_HOST', 'localhost') + EMPLOYEE_DB_USER = os.environ.get('EMPLOYEE_DB_USER', '') + EMPLOYEE_DB_PASSWORD = os.environ.get('EMPLOYEE_DB_PASSWORD', '') + EMPLOYEE_DB_NAME = os.environ.get('EMPLOYEE_DB_NAME', 'wjf_employees') + + # Read-write CMMC USB check-in/out database (separate from the app DB). + # Credentials come from the environment; never hardcode them in source. + # No safe default for the password: unset means empty, the connection + # fails loud rather than silently trying a guessed credential. + CMMC_USB_DB_HOST = os.environ.get('CMMC_USB_DB_HOST', 'localhost') + CMMC_USB_DB_USER = os.environ.get('CMMC_USB_DB_USER', '') + CMMC_USB_DB_PASSWORD = os.environ.get('CMMC_USB_DB_PASSWORD', '') + CMMC_USB_DB_NAME = os.environ.get('CMMC_USB_DB_NAME', 'cmmc_usb') + + CACHE_TYPE = 'SimpleCache' + CACHE_DEFAULT_TIMEOUT = 600 + + # IP-based login rate limit (fixed window). Defense in depth atop the + # per-account lockout. Backed by the existing cache extension. + AUTH_RATELIMIT_ENABLED = os.environ.get( + 'AUTH_RATELIMIT_ENABLED', 'true').lower() == 'true' + AUTH_RATELIMIT_MAX = int(os.environ.get('AUTH_RATELIMIT_MAX', 30)) + AUTH_RATELIMIT_WINDOW_SECONDS = int( + os.environ.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300)) + + DEFAULT_PAGE_SIZE = 20 + MAX_PAGE_SIZE = 100 + + +class DevelopmentConfig(Config): + """Development configuration.""" + + DEBUG = True + SQLALCHEMY_ECHO = True + + SQLALCHEMY_DATABASE_URI = os.environ.get( + 'DATABASE_URL', + 'mysql+pymysql://root:changeme@127.0.0.1:3306/shopdb_flask', + ) + + +class TestingConfig(Config): + """Testing configuration.""" + + TESTING = True + # Off by default so login-heavy fixtures do not trip the limiter; tests + # that exercise rate limiting flip it on via app.config override. + AUTH_RATELIMIT_ENABLED = False + SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' + SQLALCHEMY_ENGINE_OPTIONS = { + 'connect_args': {'check_same_thread': False}, + 'poolclass': StaticPool, + } + JWT_ACCESS_TOKEN_EXPIRES = timedelta(seconds=5) + + +class ProductionConfig(Config): + """Production configuration. + + Validation is deferred to validate() so that importing this class in a + non-production environment (tests, dev, tooling) does not raise. + create_app() invokes validate() when config_name == 'production' so + a misconfigured production deploy still fails loud at boot. + """ + + DEBUG = False + SQLALCHEMY_ECHO = False + + JWT_COOKIE_SECURE = True + JWT_COOKIE_CSRF_PROTECT = True + + @classmethod + def validate(cls): + """Verify production config is safe. Called from create_app.""" + secret_key = os.environ.get('SECRET_KEY', '') + jwt_secret = os.environ.get('JWT_SECRET_KEY', '') + database_url = os.environ.get('DATABASE_URL', '') + cors_raw = os.environ.get('CORS_ORIGINS', '').strip() + + insecure_defaults = { + 'dev-secret-key-change-in-production', + 'jwt-secret-key-change-in-production', + } + + if not secret_key or secret_key in insecure_defaults: + raise ConfigError( + 'SECRET_KEY is required in production and must not be the ' + 'development default. Set a strong random value in the ' + 'environment before starting the app.' + ) + if not jwt_secret or jwt_secret in insecure_defaults: + raise ConfigError( + 'JWT_SECRET_KEY is required in production and must not be ' + 'the development default. Set a strong random value in the ' + 'environment before starting the app.' + ) + if not database_url: + raise ConfigError( + 'DATABASE_URL is required in production. No fallback to a ' + 'development localhost URL is permitted.' + ) + if not cors_raw or cors_raw == '*': + raise ConfigError( + 'CORS_ORIGINS must be a comma-separated allowlist of ' + 'explicit origins in production. Wildcard "*" is not ' + 'permitted. Example: ' + 'CORS_ORIGINS=https://shopdb.example.com,https://shopdb-mirror.example.com' + ) + + +config = { + 'development': DevelopmentConfig, + 'testing': TestingConfig, + 'production': ProductionConfig, + 'default': DevelopmentConfig, +} diff --git a/shopdb/core/__init__.py b/shopdb/core/__init__.py new file mode 100644 index 00000000..6f4aecfc --- /dev/null +++ b/shopdb/core/__init__.py @@ -0,0 +1 @@ +"""Core module - always loaded.""" diff --git a/shopdb/core/api/__init__.py b/shopdb/core/api/__init__.py new file mode 100644 index 00000000..2a699d3f --- /dev/null +++ b/shopdb/core/api/__init__.py @@ -0,0 +1,51 @@ +"""Core API blueprints.""" + +from .auth import auth_bp +from .assets import assets_bp +from .modeltypes import modeltypes_bp +from .plugins import plugins_bp +from .vendors import vendors_bp +from .models import models_bp +from .businessunits import businessunits_bp +from .locations import locations_bp +from .operatingsystems import operatingsystems_bp +from .dashboard import dashboard_bp +from .dashboarddefaults import dashboarddefaults_bp +from .applications import applications_bp +from .supportteams import supportteams_bp +from .search import search_bp +from .reports import reports_bp +from .collector import collector_bp +from .settings import settings_bp +from .auditlogs import auditlogs_bp +from .users import users_bp +from .customfields import customfields_bp +from .setup import setup_bp +from .pluginui import pluginui_bp +from .apitokens import apitokens_bp + +__all__ = [ + 'auth_bp', + 'assets_bp', + 'modeltypes_bp', + 'plugins_bp', + 'vendors_bp', + 'models_bp', + 'businessunits_bp', + 'locations_bp', + 'operatingsystems_bp', + 'dashboard_bp', + 'dashboarddefaults_bp', + 'applications_bp', + 'supportteams_bp', + 'search_bp', + 'reports_bp', + 'collector_bp', + 'settings_bp', + 'auditlogs_bp', + 'users_bp', + 'customfields_bp', + 'setup_bp', + 'pluginui_bp', + 'apitokens_bp', +] diff --git a/shopdb/core/api/apitokens.py b/shopdb/core/api/apitokens.py new file mode 100644 index 00000000..c06b5c79 --- /dev/null +++ b/shopdb/core/api/apitokens.py @@ -0,0 +1,171 @@ +"""Personal API token management endpoints. + +Any authenticated user may manage their OWN tokens; an admin may list or revoke +anyone's. Endpoints are jwt_required (a token must be bootstrapped from a real +login or an existing token). The full secret is returned ONCE, on create. +""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required, current_user + +from shopdb.extensions import db +from shopdb.core.models import ApiToken, AuditLog +from shopdb.utils.responses import success_response, error_response, ErrorCodes +from shopdb.utils.authz import require_permission +from shopdb.utils.import_mode import parse_import_datetime + +apitokens_bp = Blueprint('apitokens', __name__) + + +def _validate_scopes(scopes, owner): + """Validate a scopes payload against the token OWNER. Return + (scopelist_or_none, error_response); the second is None when valid. + + scopes may be None/absent (unscoped) or a list of permission names. Every + name must exist in the catalog AND be held by the owner (the scope ceiling): + a token can never grant more than its owner holds. Admins hold everything. + """ + if scopes is None: + return None, None + if not isinstance(scopes, list) or not all(isinstance(s, str) for s in scopes): + return None, error_response( + ErrorCodes.VALIDATION_ERROR, + 'scopes must be a list of permission names') + unknown = ApiToken.unknown_scope_names(scopes) + if unknown: + return None, error_response( + ErrorCodes.VALIDATION_ERROR, + 'Unknown permission names: ' + ', '.join(unknown)) + # Scope ceiling: the owner must actually hold each scoped permission. + disallowed = [n for n in scopes if not owner.haspermission(n)] + if disallowed: + return None, error_response( + ErrorCodes.VALIDATION_ERROR, + 'Permissions not held by the token owner: ' + ', '.join(disallowed)) + return scopes, None + + +@apitokens_bp.route('', methods=['GET']) +@jwt_required() +def list_apitokens(): + """List the caller's own tokens. Admins may pass ?all=true for everyone's. + + Never returns hashes or secrets. + """ + wants_all = request.args.get('all', 'false').lower() == 'true' + is_admin = current_user.hasrole('admin') + + query = ApiToken.query + if wants_all and is_admin: + include_owner = True + else: + query = query.filter(ApiToken.userid == current_user.userid) + include_owner = False + + query = query.order_by(ApiToken.createddate.desc()) + tokens = [t.to_dict(include_owner=include_owner) for t in query.all()] + return success_response(tokens) + + +@apitokens_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('apitokens.create') +def create_apitoken(): + """Create a token for the caller. Returns the full secret ONCE. + + Optional scopes limit the token to a subset of the caller's own + permissions; absent/null means an unscoped token acting as the caller. + """ + data = request.get_json() or {} + + name = (data.get('name') or '').strip() + if not name: + return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') + + expiresat = None + if data.get('expiresat'): + expiresat = parse_import_datetime(data.get('expiresat')) + if expiresat is None: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'expiresat is not a valid date/datetime') + + scopelist, scope_error = _validate_scopes(data.get('scopes'), current_user) + if scope_error is not None: + return scope_error + + secret = ApiToken.generate_secret() + token = ApiToken( + userid=current_user.userid, + name=name, + tokenprefix=ApiToken.prefix_of(secret), + tokenhash=ApiToken.hash_secret(secret), + expiresat=expiresat, + ) + token.scopelist = scopelist + db.session.add(token) + db.session.flush() + + AuditLog.log('created', 'ApiToken', entityid=token.tokenid, entityname=name) + db.session.commit() + + result = token.to_dict() + # The secret appears here and NOWHERE else, ever. Not stored, not logged. + result['secret'] = secret + result['warning'] = ('Save this token now. It will not be shown again. ' + 'Store it somewhere safe.') + return success_response(result, message='Token created', http_code=201) + + +@apitokens_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('apitokens.create') +def update_apitoken(tokenid: int): + """Rename, rescope, or deactivate a token. Own token, or any if admin. + + Scopes are validated against the token OWNER's permissions (the ceiling), + not the editor's - so an admin rescoping someone else's token still cannot + grant that owner more than the owner holds. + """ + token = db.session.get(ApiToken, tokenid) + if token is None: + return error_response(ErrorCodes.NOT_FOUND, 'Token not found', http_code=404) + + if token.userid != current_user.userid and not current_user.hasrole('admin'): + return error_response(ErrorCodes.FORBIDDEN, + 'You may only manage your own tokens', http_code=403) + + data = request.get_json() or {} + if 'name' in data: + newname = (data.get('name') or '').strip() + if not newname: + return error_response(ErrorCodes.VALIDATION_ERROR, 'name cannot be empty') + token.name = newname + if 'isactive' in data: + token.isactive = bool(data['isactive']) + if 'scopes' in data: + scopelist, scope_error = _validate_scopes(data.get('scopes'), token.user) + if scope_error is not None: + return scope_error + token.scopelist = scopelist + + db.session.commit() + return success_response(token.to_dict(), message='Token updated') + + +@apitokens_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('apitokens.create') +def revoke_apitoken(tokenid: int): + """Revoke (deactivate) a token. Own token, or any if admin.""" + token = db.session.get(ApiToken, tokenid) + if token is None: + return error_response(ErrorCodes.NOT_FOUND, 'Token not found', http_code=404) + + if token.userid != current_user.userid and not current_user.hasrole('admin'): + return error_response(ErrorCodes.FORBIDDEN, + 'You may only manage your own tokens', http_code=403) + + token.isactive = False + AuditLog.log('deleted', 'ApiToken', entityid=token.tokenid, entityname=token.name) + db.session.commit() + return success_response(message='Token revoked') diff --git a/shopdb/core/api/applications.py b/shopdb/core/api/applications.py new file mode 100644 index 00000000..8e4d1bad --- /dev/null +++ b/shopdb/core/api/applications.py @@ -0,0 +1,462 @@ +"""Applications API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import ( + Application, AppVersion, AuditLog +) +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query +from shopdb.utils.import_mode import apply_import_timestamps + + +def _computer_models(): + """Lazily import the computers plugin models, or None if unavailable. + + Application install-tracking is a join over the computers plugin's tables. + Importing lazily keeps the applications API importable when the computers + plugin is absent or disabled. + """ + try: + from plugins.computers.models import Computer, ComputerInstalledApp + return Computer, ComputerInstalledApp + except ImportError: + return None + + +def _installed_count(appid): + """Count active installs of an app, 0 when the computers plugin is absent.""" + models = _computer_models() + if not models: + return 0 + _, ComputerInstalledApp = models + return ComputerInstalledApp.query.filter_by(appid=appid, isactive=True).count() + + +def _related_kb(appid): + """Knowledge Base articles linked to this app, [] when the KB plugin is + absent. Lazy + guarded so core stays decoupled from the plugin.""" + try: + from plugins.knowledgebase.models import KnowledgeBase + except ImportError: + return [] + rows = KnowledgeBase.query.filter_by(appid=appid, isactive=True).order_by( + KnowledgeBase.shortdescription).all() + return [{'linkid': k.linkid, 'shortdescription': k.shortdescription, + 'linkurl': k.linkurl, 'keywords': k.keywords} for k in rows] + + +def _require_computer_models(): + """Resolve (Computer, ComputerInstalledApp) or a 503 response tuple. + + Usage: `models, err = _require_computer_models(); if err: return err`. + """ + models = _computer_models() + if not models: + return None, error_response( + ErrorCodes.INTERNAL_ERROR, + 'Install tracking requires the computers plugin', + http_code=503) + return models, None + + +from shopdb.utils.authz import require_permission + +applications_bp = Blueprint('applications', __name__) + + +@applications_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_applications(): + """List all applications.""" + page, per_page = get_pagination_params(request) + + query = Application.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Application.isactive == True) + + # Filter out hidden unless specifically requested + if request.args.get('showhidden', 'false').lower() != 'true': + query = query.filter(Application.ishidden == False) + + # Filter by installable + if request.args.get('installable') is not None: + installable = request.args.get('installable').lower() == 'true' + query = query.filter(Application.isinstallable == installable) + + # Exact-match natural-key lookup for idempotent import (app name). + if exactappname := request.args.get('appname'): + query = query.filter(Application.appname == exactappname) + + if search := request.args.get('search'): + query = query.filter( + db.or_( + Application.appname.ilike(f'%{search}%'), + Application.appdescription.ilike(f'%{search}%') + ) + ) + + query = query.order_by(Application.appname) + + items, total = paginate_query(query, page, per_page) + data = [] + for app in items: + # to_dict already flattens supportteamname/teamurl/contacts. + app_dict = app.to_dict() + app_dict['installedcount'] = _installed_count(app.appid) + data.append(app_dict) + + return paginated_response(data, page, per_page, total) + + +@applications_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_application(app_id: int): + """Get a single application with details.""" + app = db.session.get(Application, app_id) + + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + data = app.to_dict() + data['versions'] = [v.to_dict() for v in app.versions.filter_by(isactive=True).order_by(AppVersion.version.desc()).all()] + data['installedcount'] = _installed_count(app.appid) + data['knowledgebase'] = _related_kb(app.appid) + + return success_response(data) + + +@applications_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('applications.create') +def create_application(): + """Create a new application.""" + data = request.get_json() + + if not data or not data.get('appname'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'appname is required') + + if Application.query.filter_by(appname=data['appname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Application '{data['appname']}' already exists", + http_code=409 + ) + + app = Application( + appname=data['appname'], + appdescription=data.get('appdescription'), + supportteamid=data.get('supportteamid'), + isinstallable=data.get('isinstallable', False), + applicationnotes=data.get('applicationnotes'), + installpath=data.get('installpath'), + applicationlink=data.get('applicationlink'), + documentationpath=data.get('documentationpath'), + ishidden=data.get('ishidden', False), + isprinter=data.get('isprinter', False), + islicenced=data.get('islicenced', False), + isrequired=data.get('isrequired', False), + image=data.get('image') + ) + + db.session.add(app) + apply_import_timestamps(app, data) + db.session.flush() + + AuditLog.log('created', 'Application', entityid=app.appid, entityname=app.appname) + + db.session.commit() + + return success_response(app.to_dict(), message='Application created', http_code=201) + + +@applications_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('applications.edit') +def update_application(app_id: int): + """Update an application.""" + app = db.session.get(Application, app_id) + + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'appname' in data and data['appname'] != app.appname: + if Application.query.filter_by(appname=data['appname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Application '{data['appname']}' already exists", + http_code=409 + ) + + fields = [ + 'appname', 'appdescription', 'supportteamid', 'isinstallable', + 'applicationnotes', 'installpath', 'applicationlink', 'documentationpath', + 'ishidden', 'isprinter', 'islicenced', 'isrequired', 'image', 'isactive' + ] + + changes = {} + for key in fields: + if key in data: + old_val = getattr(app, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(app, key, data[key]) + + if changes: + AuditLog.log('updated', 'Application', entityid=app.appid, + entityname=app.appname, changes=changes) + + apply_import_timestamps(app, data) + db.session.commit() + return success_response(app.to_dict(), message='Application updated') + + +@applications_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('applications.delete') +def delete_application(app_id: int): + """Delete (deactivate) an application.""" + app = db.session.get(Application, app_id) + + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + app.isactive = False + + AuditLog.log('deleted', 'Application', entityid=app.appid, entityname=app.appname) + + db.session.commit() + + return success_response(message='Application deleted') + + +# ---- Versions ---- + +@applications_bp.route('//versions', methods=['GET']) +@jwt_required(optional=True) +def list_versions(app_id: int): + """List all versions for an application.""" + app = db.session.get(Application, app_id) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + versions = app.versions.filter_by(isactive=True).order_by(AppVersion.version.desc()).all() + return success_response([v.to_dict() for v in versions]) + + +@applications_bp.route('//versions', methods=['POST']) +@jwt_required() +@require_permission('applications.create') +def create_version(app_id: int): + """Create a new version for an application.""" + app = db.session.get(Application, app_id) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + data = request.get_json() + if not data or not data.get('version'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'version is required') + + if AppVersion.query.filter_by(appid=app_id, version=data['version']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Version '{data['version']}' already exists for this application", + http_code=409 + ) + + version = AppVersion( + appid=app_id, + version=data['version'], + releasedate=data.get('releasedate'), + notes=data.get('notes') + ) + + db.session.add(version) + # Preserve the legacy release/added timestamps in import mode (no-op + # otherwise), so imported version history keeps its original dates. + apply_import_timestamps(version, data) + db.session.commit() + + return success_response(version.to_dict(), message='Version created', http_code=201) + + +# ---- Computers with this app installed ---- + +@applications_bp.route('//installed', methods=['GET']) +@jwt_required(optional=True) +def list_installed_machines(app_id: int): + """List all computers that have this application installed.""" + app = db.session.get(Application, app_id) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + models, err = _require_computer_models() + if err: + return err + _, ComputerInstalledApp = models + + installed = ComputerInstalledApp.query.filter_by( + appid=app_id, isactive=True).all() + data = [] + for i in installed: + comp = i.computer + version = i.installedversion + if not version and i.appversion: + version = i.appversion.version + item = { + 'id': i.id, + 'computerid': i.computerid, + 'version': version, + } + if comp: + item['computer'] = { + 'computerid': comp.computerid, + 'assetnumber': comp.asset.assetnumber if comp.asset else None, + 'hostname': comp.hostname, + } + data.append(item) + + return success_response(data) + + +# ---- Installed Apps (per computer) ---- + +@applications_bp.route('/machines/', methods=['GET']) +@jwt_required(optional=True) +def list_machine_applications(machine_id: int): + """List all applications installed on a computer.""" + models, err = _require_computer_models() + if err: + return err + Computer, ComputerInstalledApp = models + + comp = db.session.get(Computer, machine_id) + if not comp: + return error_response(ErrorCodes.NOT_FOUND, 'Computer not found', http_code=404) + + installed = comp.installedapps.filter_by(isactive=True).all() + return success_response([i.to_dict() for i in installed]) + + +@applications_bp.route('/machines/', methods=['POST']) +@jwt_required() +@require_permission('applications.create') +def install_application(machine_id: int): + """Install an application on a computer.""" + models, err = _require_computer_models() + if err: + return err + Computer, ComputerInstalledApp = models + + comp = db.session.get(Computer, machine_id) + if not comp: + return error_response(ErrorCodes.NOT_FOUND, 'Computer not found', http_code=404) + + data = request.get_json() + if not data or not data.get('appid'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'appid is required') + + app = db.session.get(Application, data['appid']) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + existing = ComputerInstalledApp.query.filter_by( + computerid=machine_id, + appid=data['appid'] + ).first() + + if existing: + if existing.isactive: + return error_response( + ErrorCodes.CONFLICT, + 'Application already installed on this computer', + http_code=409 + ) + existing.isactive = True + existing.appversionid = data.get('appversionid') + existing.installeddate = db.func.now() + db.session.commit() + return success_response(existing.to_dict(), message='Application reinstalled') + + installed = ComputerInstalledApp( + computerid=machine_id, + appid=data['appid'], + appversionid=data.get('appversionid') + ) + + db.session.add(installed) + db.session.commit() + + return success_response(installed.to_dict(), message='Application installed', http_code=201) + + +@applications_bp.route('/machines//', methods=['DELETE']) +@jwt_required() +@require_permission('applications.delete') +def uninstall_application(machine_id: int, app_id: int): + """Uninstall an application from a computer.""" + models, err = _require_computer_models() + if err: + return err + _, ComputerInstalledApp = models + + installed = ComputerInstalledApp.query.filter_by( + computerid=machine_id, + appid=app_id, + isactive=True + ).first() + + if not installed: + return error_response(ErrorCodes.NOT_FOUND, 'Application not installed on this computer', http_code=404) + + installed.isactive = False + db.session.commit() + + return success_response(message='Application uninstalled') + + +@applications_bp.route('/machines//', methods=['PUT']) +@jwt_required() +@require_permission('applications.edit') +def update_installed_app(machine_id: int, app_id: int): + """Update installed application (e.g., change version).""" + models, err = _require_computer_models() + if err: + return err + _, ComputerInstalledApp = models + + installed = ComputerInstalledApp.query.filter_by( + computerid=machine_id, + appid=app_id, + isactive=True + ).first() + + if not installed: + return error_response(ErrorCodes.NOT_FOUND, 'Application not installed on this computer', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'appversionid' in data: + installed.appversionid = data['appversionid'] + + db.session.commit() + + return success_response(installed.to_dict(), message='Installation updated') + +# Support teams + contacts now live in the supportteams blueprint +# (/api/supportteams), replacing the legacy appowners pair. diff --git a/shopdb/core/api/assets.py b/shopdb/core/api/assets.py new file mode 100644 index 00000000..2403765d --- /dev/null +++ b/shopdb/core/api/assets.py @@ -0,0 +1,1170 @@ +"""Assets API endpoints - unified asset queries.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required +from sqlalchemy.orm import joinedload, subqueryload + +from shopdb.extensions import db +from shopdb.core.models import Asset, AssetType, AssetStatus, AssetRelationship, RelationshipType +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from shopdb.utils.authz import require_permission +from shopdb.utils.import_mode import apply_import_timestamps + +assets_bp = Blueprint('assets', __name__) + + +# ============================================================================= +# Asset Types +# ============================================================================= + +@assets_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_asset_types(): + """List all asset types.""" + page, per_page = get_pagination_params(request) + + query = AssetType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(AssetType.isactive == True) + + query = query.order_by(AssetType.assettype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@assets_bp.route('/types/', methods=['GET']) +@jwt_required(optional=True) +def get_asset_type(type_id: int): + """Get a single asset type.""" + t = db.session.get(AssetType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@assets_bp.route('/types', methods=['POST']) +@jwt_required() +@require_permission('assets.create') +def create_asset_type(): + """Create a new asset type.""" + data = request.get_json() + + if not data or not data.get('assettype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assettype is required') + + if AssetType.query.filter_by(assettype=data['assettype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset type '{data['assettype']}' already exists", + http_code=409 + ) + + t = AssetType( + assettype=data['assettype'], + pluginname=data.get('pluginname'), + tablename=data.get('tablename'), + description=data.get('description'), + icon=data.get('icon'), + color=data.get('color') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Asset type created', http_code=201) + + +@assets_bp.route('/types/', methods=['PUT']) +@jwt_required() +@require_permission('assets.edit') +def update_asset_type(type_id: int): + """Update an asset type's display fields (color/icon/description). The name, + plugin, and table are structural and not editable here.""" + t = db.session.get(AssetType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Asset type not found', http_code=404) + data = request.get_json() or {} + for key in ('description', 'icon', 'color', 'isactive'): + if key in data: + setattr(t, key, data[key]) + db.session.commit() + return success_response(t.to_dict(), message='Asset type updated') + + +# ============================================================================= +# Asset Statuses +# ============================================================================= + +@assets_bp.route('/statuses', methods=['GET']) +@jwt_required(optional=True) +def list_asset_statuses(): + """List all asset statuses.""" + page, per_page = get_pagination_params(request) + + query = AssetStatus.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(AssetStatus.isactive == True) + + query = query.order_by(AssetStatus.status) + + items, total = paginate_query(query, page, per_page) + data = [s.to_dict() for s in items] + + return paginated_response(data, page, per_page, total) + + +@assets_bp.route('/statuses/', methods=['GET']) +@jwt_required(optional=True) +def get_asset_status(status_id: int): + """Get a single asset status.""" + s = db.session.get(AssetStatus, status_id) + + if not s: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset status with ID {status_id} not found', + http_code=404 + ) + + return success_response(s.to_dict()) + + +@assets_bp.route('/statuses', methods=['POST']) +@jwt_required() +@require_permission('assets.create') +def create_asset_status(): + """Create a new asset status.""" + data = request.get_json() + + if not data or not data.get('status'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'status is required') + + if AssetStatus.query.filter_by(status=data['status']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset status '{data['status']}' already exists", + http_code=409 + ) + + s = AssetStatus( + status=data['status'], + description=data.get('description'), + color=data.get('color') + ) + + db.session.add(s) + db.session.commit() + + return success_response(s.to_dict(), message='Asset status created', http_code=201) + + +@assets_bp.route('/statuses/', methods=['PUT']) +@jwt_required() +@require_permission('assets.edit') +def update_asset_status(status_id: int): + """Update an asset status.""" + s = db.session.get(AssetStatus, status_id) + if not s: + return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found', + http_code=404) + + data = request.get_json() or {} + + # Conflict check on rename + if 'status' in data and data['status'] != s.status: + if AssetStatus.query.filter_by(status=data['status']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset status '{data['status']}' already exists", + http_code=409 + ) + + for key in ('status', 'description', 'color', 'isactive'): + if key in data: + setattr(s, key, data[key]) + + db.session.commit() + return success_response(s.to_dict(), message='Asset status updated') + + +@assets_bp.route('/statuses/', methods=['DELETE']) +@jwt_required() +@require_permission('assets.delete') +def delete_asset_status(status_id: int): + """Delete an asset status. Refused if any asset still uses it.""" + s = db.session.get(AssetStatus, status_id) + if not s: + return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found', + http_code=404) + + inuse = Asset.query.filter_by(statusid=status_id).count() + if inuse: + return error_response( + ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} asset(s) still use this status", + http_code=409 + ) + + db.session.delete(s) + db.session.commit() + return success_response(message='Asset status deleted') + + +# ============================================================================= +# Relationship Types +# ============================================================================= + +@assets_bp.route('/relationshiptypes', methods=['GET']) +@jwt_required(optional=True) +def list_relationship_types(): + """List all asset relationship types.""" + types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all() + return success_response([_rel_type_dict(t) for t in types]) + + +@assets_bp.route('/relationshiptypes', methods=['POST']) +@jwt_required() +@require_permission('assets.create') +def create_relationship_type(): + """Create a new asset relationship type.""" + data = request.get_json() + if not data or not data.get('relationshiptype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptype is required') + + if RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Relationship type '{data['relationshiptype']}' already exists", + http_code=409 + ) + + rel_type = RelationshipType( + relationshiptype=data['relationshiptype'], + description=data.get('description'), + color=data.get('color'), + isdirectional=data.get('isdirectional', True) + ) + db.session.add(rel_type) + db.session.commit() + + return success_response(_rel_type_dict(rel_type), message='Relationship type created', http_code=201) + + +def _rel_type_dict(t): + return { + 'relationshiptypeid': t.relationshiptypeid, + 'relationshiptype': t.relationshiptype, + 'description': t.description, + 'color': t.color, + 'isdirectional': t.isdirectional, + # read-only view of the propagation rails (seed/CLI-managed, no UI) + 'propagatesthrough': [ + { + 'relationshiptypeid': p.relationshiptypeid, + 'relationshiptype': p.relationshiptype, + 'isdirectional': p.isdirectional, + } + for p in t.propagatesthrough + ], + } + + +@assets_bp.route('/relationshiptypes/', methods=['PUT']) +@jwt_required() +@require_permission('assets.edit') +def update_relationship_type(type_id: int): + """Update a relationship type.""" + t = db.session.get(RelationshipType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Relationship type not found', http_code=404) + data = request.get_json() or {} + if 'relationshiptype' in data and data['relationshiptype'] != t.relationshiptype: + if RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first(): + return error_response(ErrorCodes.CONFLICT, + f"Relationship type '{data['relationshiptype']}' already exists", http_code=409) + for key in ('relationshiptype', 'description', 'color', 'isdirectional'): + if key in data: + setattr(t, key, data[key]) + db.session.commit() + return success_response(_rel_type_dict(t), message='Relationship type updated') + + +@assets_bp.route('/relationshiptypes/', methods=['DELETE']) +@jwt_required() +@require_permission('assets.delete') +def delete_relationship_type(type_id: int): + """Delete a relationship type. Refused if any relationship still uses it.""" + t = db.session.get(RelationshipType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Relationship type not found', http_code=404) + inuse = AssetRelationship.query.filter_by(relationshiptypeid=type_id).count() + if inuse: + return error_response(ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} relationship(s) still use this type", http_code=409) + db.session.delete(t) + db.session.commit() + return success_response(message='Relationship type deleted') + + +# ============================================================================= +# Assets +# ============================================================================= + +@assets_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_assets(): + """ + List all assets with filtering and pagination. + + Query parameters: + - page: Page number (default: 1) + - per_page: Items per page (default: 20, max: 100) + - active: Filter by active status (default: true) + - search: Search by assetnumber or name + - type: Filter by asset type name (e.g., 'machine', 'computer') + - type_id: Filter by asset type ID + - status_id: Filter by status ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + - include_type_data: Include category-specific extension data (default: false) + """ + page, per_page = get_pagination_params(request) + + query = Asset.query + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%') + ) + ) + + # Type filter by name + if type_name := request.args.get('type'): + query = query.join(AssetType).filter(AssetType.assettype == type_name) + + # Type filter by ID + if type_id := request.args.get('typeid', request.args.get('type_id')): + query = query.filter(Asset.assettypeid == int(type_id)) + + # Status filter + if status_id := request.args.get('statusid', request.args.get('status_id')): + query = query.filter(Asset.statusid == int(status_id)) + + # Location filter + if location_id := request.args.get('locationid', request.args.get('location_id')): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Sorting + sort_by = request.args.get('sort', 'assetnumber') + sort_dir = request.args.get('dir', 'asc') + + sort_columns = { + 'assetnumber': Asset.assetnumber, + 'name': Asset.name, + 'createddate': Asset.createddate, + 'modifieddate': Asset.modifieddate, + } + + if sort_by in sort_columns: + col = sort_columns[sort_by] + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + else: + query = query.order_by(Asset.assetnumber) + + items, total = paginate_query(query, page, per_page) + + # Include type data if requested + include_type_data = request.args.get('include_type_data', 'false').lower() == 'true' + data = [a.to_dict(include_type_data=include_type_data) for a in items] + + return paginated_response(data, page, per_page, total) + + +@assets_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_asset(asset_id: int): + """ + Get a single asset with full details. + + Query parameters: + - include_type_data: Include category-specific extension data (default: true) + """ + asset = db.session.get(Asset, asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + include_type_data = request.args.get('include_type_data', 'true').lower() != 'false' + return success_response(asset.to_dict(include_type_data=include_type_data)) + + +@assets_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('assets.create') +def create_asset(): + """Create a new asset.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Validate required fields + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + if not data.get('assettypeid'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assettypeid is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Validate foreign keys exist + if not db.session.get(AssetType, data['assettypeid']): + return error_response( + ErrorCodes.VALIDATION_ERROR, + f"Asset type with ID {data['assettypeid']} not found" + ) + + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + serialnumber=data.get('serialnumber'), + assettypeid=data['assettypeid'], + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapx=data.get('mapx'), + mapy=data.get('mapy'), + notes=data.get('notes') + ) + + db.session.add(asset) + apply_import_timestamps(asset, data) + db.session.commit() + + return success_response(asset.to_dict(), message='Asset created', http_code=201) + + +@assets_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('assets.edit') +def update_asset(asset_id: int): + """Update an asset.""" + asset = db.session.get(Asset, asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Update allowed fields + allowed_fields = [ + 'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid', + 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive' + ] + + for key in allowed_fields: + if key in data: + setattr(asset, key, data[key]) + + apply_import_timestamps(asset, data) + db.session.commit() + return success_response(asset.to_dict(), message='Asset updated') + + +@assets_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('assets.delete') +def delete_asset(asset_id: int): + """Delete (soft delete) an asset.""" + asset = db.session.get(Asset, asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + asset.isactive = False + db.session.commit() + + return success_response(message='Asset deleted') + + +@assets_bp.route('/lookup/', methods=['GET']) +@jwt_required(optional=True) +def lookup_asset_by_number(assetnumber: str): + """ + Look up an asset by its asset number. + + Useful for finding the asset ID when you only have the machine/asset number. + """ + asset = Asset.query.filter_by(assetnumber=assetnumber, isactive=True).first() + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with number {assetnumber} not found', + http_code=404 + ) + + return success_response(asset.to_dict(include_type_data=True)) + + +# ============================================================================= +# Asset Relationships +# ============================================================================= + +def _partners_via(assetid, throughtypeid): + """Return the set of asset ids linked to assetid via throughtypeid. + + Direction-blind: matches rows where assetid is source OR target, so a + symmetric through-type (Dualpath) stored in either direction is caught. + """ + rows = AssetRelationship.query.filter( + AssetRelationship.relationshiptypeid == throughtypeid, + AssetRelationship.isactive == True, + db.or_( + AssetRelationship.sourceassetid == assetid, + AssetRelationship.targetassetid == assetid, + ), + ).all() + partners = set() + for row in rows: + other = row.targetassetid if row.sourceassetid == assetid else row.sourceassetid + partners.add(other) + return partners + + +def _relationship_exists(sourceid, targetid, typeid): + # existence check ignores isactive so a soft-deleted row still blocks a + # duplicate insert (the uniqueness constraint spans all rows). + return AssetRelationship.query.filter_by( + sourceassetid=sourceid, + targetassetid=targetid, + relationshiptypeid=typeid, + ).first() is not None + + +def _add_propagated(sourceid, targetid, rel, created): + # idempotent add; skip if source-T->target already exists (any isactive) + if sourceid in (targetid,): + return + if _relationship_exists(sourceid, targetid, rel.relationshiptypeid): + return + newrel = AssetRelationship( + sourceassetid=sourceid, + targetassetid=targetid, + relationshiptypeid=rel.relationshiptypeid, + label=rel.label, + ) + db.session.add(newrel) + created.append(newrel) + + +def propagate_relationship(rel): + """Fan out rel across the SYMMETRIC through-types of its type. + + A Dualpath pair is one physical dual-bay machine, so a relationship that + touches one bay must be mirrored to the partner bay - whichever end (source + or target) carries the Dualpath link. For each symmetric through-type P + (isdirectional false) in the type's propagation set we mirror BOTH ends, + keeping the relationship's direction: + + * for each partner X of the TARGET via P: create source -T-> X + * for each partner X of the SOURCE via P: create X -T-> target + + In practice only one end has partners (the bay end of a controls edge, + whichever direction the row is stored), so there is no double fan-out. + Canonical controls direction is PC -> machine (the PC is the controller: + it sends programs to the machine and receives logs); reversed legacy rows + are corrected by `flask relationships fix-controls-direction`, and + propagation preserves whatever direction the row carries. Same-direction + idempotency: controls is directional, so we skip when the same + source-T->target row already exists. Returns the newly created + AssetRelationship rows (added to the session, not committed). + + Directional through-types (partof) are declared but NOT consumed: the + parent/child fan-out direction is ambiguous, so their propagation semantics + are deferred and partof stays inert exactly as before. + """ + created = [] + reltype = rel.relationshiptype or db.session.get(RelationshipType, rel.relationshiptypeid) + if not reltype: + return created + for through in reltype.propagatesthrough: + if through.isdirectional: + continue # directional propagation deferred (parent/child ambiguity) + # target-side partners inherit being controlled BY the source + for partnerid in _partners_via(rel.targetassetid, through.relationshiptypeid): + if partnerid != rel.sourceassetid: + _add_propagated(rel.sourceassetid, partnerid, rel, created) + # source-side partners inherit controlling the target + for partnerid in _partners_via(rel.sourceassetid, through.relationshiptypeid): + if partnerid != rel.targetassetid: + _add_propagated(partnerid, rel.targetassetid, rel, created) + return created + + +@assets_bp.route('//relationships', methods=['GET']) +@jwt_required(optional=True) +def get_asset_relationships(asset_id: int): + """ + Get all relationships for an asset. + + Returns both outgoing (source) and incoming (target) relationships. + """ + asset = db.session.get(Asset, asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + # Get outgoing relationships (this asset is source) + outgoing = AssetRelationship.query.filter_by( + sourceassetid=asset_id + ).filter(AssetRelationship.isactive == True).all() + + # Get incoming relationships (this asset is target) + incoming = AssetRelationship.query.filter_by( + targetassetid=asset_id + ).filter(AssetRelationship.isactive == True).all() + + outgoing_data = [] + for rel in outgoing: + r = rel.to_dict() + r['targetasset'] = rel.targetasset.to_dict() if rel.targetasset else None + r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None + # directionality drives card display (symmetric collapse vs arrows) + r['isdirectional'] = rel.relationshiptype.isdirectional if rel.relationshiptype else True + outgoing_data.append(r) + + incoming_data = [] + for rel in incoming: + r = rel.to_dict() + r['sourceasset'] = rel.sourceasset.to_dict() if rel.sourceasset else None + r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None + r['isdirectional'] = rel.relationshiptype.isdirectional if rel.relationshiptype else True + incoming_data.append(r) + + return success_response({ + 'outgoing': outgoing_data, + 'incoming': incoming_data + }) + + +@assets_bp.route('/relationships', methods=['POST']) +@jwt_required() +@require_permission('assets.create') +def create_asset_relationship(): + """Create a relationship between two assets. + + After the row is created, the relationship is fanned out across its type's + propagation rails (relationshiptypepropagations). For each SYMMETRIC + through-type (e.g. Dualpath) the edge is mirrored to the partner of + whichever endpoint carries the through-type link, keeping direction. + Concretely, `controls` between a bay of a dual-bay machine and its PC is + also created for the partner bay, since a Dualpath pair is one physical + machine with a single controller. Directional through-types (partof) are + declared but not consumed yet. The response carries `propagated` and + `propagatedcount`. Propagation pairs are seed/CLI-managed (there is no + management UI); backfill legacy data with `flask relationships propagate`. + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Validate required fields + required = ['sourceassetid', 'targetassetid', 'relationshiptypeid'] + for field in required: + if not data.get(field): + return error_response(ErrorCodes.VALIDATION_ERROR, f'{field} is required') + + source_id = data['sourceassetid'] + target_id = data['targetassetid'] + type_id = data['relationshiptypeid'] + + # Validate assets exist + if not db.session.get(Asset, source_id): + return error_response(ErrorCodes.NOT_FOUND, f'Source asset {source_id} not found', http_code=404) + if not db.session.get(Asset, target_id): + return error_response(ErrorCodes.NOT_FOUND, f'Target asset {target_id} not found', http_code=404) + if not db.session.get(RelationshipType, type_id): + return error_response(ErrorCodes.NOT_FOUND, f'Relationship type {type_id} not found', http_code=404) + + # Check for duplicate relationship + existing = AssetRelationship.query.filter_by( + sourceassetid=source_id, + targetassetid=target_id, + relationshiptypeid=type_id + ).first() + + if existing: + return error_response( + ErrorCodes.CONFLICT, + 'This relationship already exists', + http_code=409 + ) + + rel = AssetRelationship( + sourceassetid=source_id, + targetassetid=target_id, + relationshiptypeid=type_id, + notes=data.get('notes') + ) + + db.session.add(rel) + apply_import_timestamps(rel, data) + db.session.flush() + + # fan out across symmetric propagation rails (e.g. Dualpath partner bays) + propagated = propagate_relationship(rel) + for prop in propagated: + apply_import_timestamps(prop, data) + db.session.commit() + + payload = rel.to_dict() + payload['propagated'] = [prop.to_dict() for prop in propagated] + payload['propagatedcount'] = len(propagated) + return success_response(payload, message='Relationship created', http_code=201) + + +@assets_bp.route('/relationships/', methods=['DELETE']) +@jwt_required() +@require_permission('assets.delete') +def delete_asset_relationship(rel_id: int): + """Delete an asset relationship. + + Deletion is manual and per-row: propagated rows (e.g. the mirrored controls + link on a Dualpath partner bay) are real independent rows and each is + deleted on its own. Removing one bay's link does NOT cascade to the other. + """ + rel = db.session.get(AssetRelationship, rel_id) + + if not rel: + return error_response( + ErrorCodes.NOT_FOUND, + f'Relationship with ID {rel_id} not found', + http_code=404 + ) + + rel.isactive = False + db.session.commit() + + return success_response(message='Relationship deleted') + + +# ============================================================================= +# Asset Communications +# ============================================================================= + +# ============================================================================= +# Unified Asset Map +# ============================================================================= + +@assets_bp.route('/map', methods=['GET']) +@jwt_required(optional=True) +def get_assets_map(): + """ + Get all assets with map positions for unified floor map display. + + Returns assets with mapx/mapy coordinates, joined with type-specific data. + + Query parameters: + - assettype: Filter by asset type name (machine, computer, network_device, printer) + - subtype: Filter by subtype ID (machinetype for machines, computertype for PCs, networkdevicetype for network, printertype for printer) + - businessunitid: Filter by business unit ID + - statusid: Filter by status ID + - locationid: Filter by location ID + - search: Search by assetnumber, name, or serialnumber + """ + from shopdb.core.models import Location, BusinessUnit, Communication + + # Eager-load all relationships to avoid N+1 queries. + # Core relationships via joinedload, extension tables via subqueryload + # with their nested relationships (vendor, model, type) also eager-loaded. + eager_options = [ + joinedload(Asset.assettype), + joinedload(Asset.status), + joinedload(Asset.location), + joinedload(Asset.businessunit), + ] + + # Eager-load plugin extension tables AND their relationships + try: + from plugins.machines.models import Machine + eager_options.append( + subqueryload(Asset.machine) + .joinedload(Machine.machinetype) + ) + eager_options.append( + subqueryload(Asset.machine) + .joinedload(Machine.vendor) + ) + eager_options.append( + subqueryload(Asset.machine) + .joinedload(Machine.model) + ) + eager_options.append( + subqueryload(Asset.machine) + .joinedload(Machine.controllervendor) + ) + eager_options.append( + subqueryload(Asset.machine) + .joinedload(Machine.controllermodel) + ) + except (ImportError, AttributeError): + pass + try: + from plugins.computers.models import Computer + eager_options.append( + subqueryload(Asset.computer) + .joinedload(Computer.computertype) + ) + eager_options.append( + subqueryload(Asset.computer) + .joinedload(Computer.operatingsystem) + ) + except (ImportError, AttributeError): + pass + try: + from plugins.network.models import NetworkDevice + eager_options.append( + subqueryload(Asset.network_device) + .joinedload(NetworkDevice.networkdevicetype) + ) + eager_options.append( + subqueryload(Asset.network_device) + .joinedload(NetworkDevice.vendor) + ) + except (ImportError, AttributeError): + pass + try: + from plugins.printers.models import Printer + eager_options.append( + subqueryload(Asset.printer) + .joinedload(Printer.printertype) + ) + eager_options.append( + subqueryload(Asset.printer) + .joinedload(Printer.vendor) + ) + eager_options.append( + subqueryload(Asset.printer) + .joinedload(Printer.model) + ) + except (ImportError, AttributeError): + pass + try: + from plugins.measuringtools.models import MeasuringTool + eager_options.append( + subqueryload(Asset.measuringtool) + .joinedload(MeasuringTool.measuringtooltype) + ) + except (ImportError, AttributeError): + pass + + query = Asset.query.options(*eager_options).filter( + Asset.isactive == True, + Asset.mapx.isnot(None), + Asset.mapy.isnot(None) + ) + + # Dualpath single-machine collapse: hide the secondary bay marker so a + # dual-bay pair shows one marker; the primary carries the partner label. + # Gated on the site setting (default on). + from shopdb.core.services.dualpath import ( + resolve_dualpath_pairs, dualpath_single_machine_enabled) + dualpath_collapse = None + if dualpath_single_machine_enabled(): + dualpath_collapse = resolve_dualpath_pairs() + if dualpath_collapse.secondaryassetids: + query = query.filter( + Asset.assetid.notin_(dualpath_collapse.secondaryassetids)) + + selected_assettype = request.args.get('assettype') + + # Filter by asset type name + if selected_assettype: + query = query.join(AssetType).filter(AssetType.assettype == selected_assettype) + + # Filter by subtype (depends on asset type) - case-insensitive matching + if subtype_id := request.args.get('subtype'): + subtype_id = int(subtype_id) + # Normalize the underscore DB form (measuring_tool, network_device) to + # the space form the branches below compare against. + asset_type_lower = ( + selected_assettype.lower().replace('_', ' ') if selected_assettype else '') + if asset_type_lower == 'machine': + try: + from plugins.machines.models import Machine + query = query.join(Machine, Machine.assetid == Asset.assetid).filter( + Machine.machinetypeid == subtype_id + ) + except ImportError: + pass + elif asset_type_lower == 'computer': + try: + from plugins.computers.models import Computer + query = query.join(Computer, Computer.assetid == Asset.assetid).filter( + Computer.computertypeid == subtype_id + ) + except ImportError: + pass + elif asset_type_lower == 'network device': + try: + from plugins.network.models import NetworkDevice + query = query.join(NetworkDevice, NetworkDevice.assetid == Asset.assetid).filter( + NetworkDevice.networkdevicetypeid == subtype_id + ) + except ImportError: + pass + elif asset_type_lower == 'printer': + try: + from plugins.printers.models import Printer + query = query.join(Printer, Printer.assetid == Asset.assetid).filter( + Printer.printertypeid == subtype_id + ) + except ImportError: + pass + elif asset_type_lower == 'measuring tool': + try: + from plugins.measuringtools.models import MeasuringTool + query = query.join( + MeasuringTool, MeasuringTool.assetid == Asset.assetid).filter( + MeasuringTool.measuringtooltypeid == subtype_id + ) + except ImportError: + pass + + # Filter by business unit + if bu_id := request.args.get('businessunitid'): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Filter by status + if status_id := request.args.get('statusid'): + query = query.filter(Asset.statusid == int(status_id)) + + # Filter by location + if location_id := request.args.get('locationid'): + query = query.filter(Asset.locationid == int(location_id)) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%') + ) + ) + + assets = query.all() + + # Batch-load primary IPs in a single query instead of N+1 per asset. + # Prefer isprimary=True IP, fall back to any IP (comtypeid=1). + asset_ids = [a.assetid for a in assets] + primary_ip_map = {} + if asset_ids: + ip_rows = db.session.query( + Communication.assetid, + Communication.ipaddress, + Communication.isprimary + ).filter( + Communication.assetid.in_(asset_ids), + Communication.comtypeid == 1, + Communication.isactive == True + ).order_by( + Communication.isprimary.desc() + ).all() + + for row in ip_rows: + # First match wins (isprimary=True sorted first) + if row.assetid not in primary_ip_map: + primary_ip_map[row.assetid] = row.ipaddress + + # Build response - all relationship data is already loaded, no extra queries + data = [] + for asset in assets: + item = { + 'assetid': asset.assetid, + 'assetnumber': asset.assetnumber, + 'name': asset.name, + 'displayname': asset.display_name, + 'serialnumber': asset.serialnumber, + 'mapx': asset.mapx, + 'mapy': asset.mapy, + 'assettype': asset.assettype.assettype if asset.assettype else None, + 'assettypeid': asset.assettypeid, + 'status': asset.status.status if asset.status else None, + 'statusid': asset.statusid, + 'statuscolor': asset.status.color if asset.status else None, + 'location': asset.location.locationname if asset.location else None, + 'locationid': asset.locationid, + 'businessunit': asset.businessunit.businessunit if asset.businessunit else None, + 'businessunitid': asset.businessunitid, + 'primaryip': primary_ip_map.get(asset.assetid), + } + + # Extension data is already eager-loaded via lazy='joined' backrefs + type_data = asset._get_extension_data() + if type_data: + item['typedata'] = type_data + + # combined-label partner for a collapsed dual-bay pair (label only) + if dualpath_collapse: + partner = dualpath_collapse.partnerbyasset.get(asset.assetid) + item['dualpathpartner'] = partner['assetnumber'] if partner else None + + data.append(item) + + # Get filter options - these are small reference tables, no N+1 concern + asset_types = AssetType.query.filter(AssetType.isactive == True).all() + types_data = [{'assettypeid': t.assettypeid, 'assettype': t.assettype, 'icon': t.icon, 'color': t.color} for t in asset_types] + + statuses = AssetStatus.query.filter(AssetStatus.isactive == True).all() + status_data = [{'statusid': s.statusid, 'status': s.status, 'color': s.color} for s in statuses] + + business_units = BusinessUnit.query.filter(BusinessUnit.isactive == True).all() + bu_data = [{'businessunitid': bu.businessunitid, 'businessunit': bu.businessunit} for bu in business_units] + + locations = Location.query.filter(Location.isactive == True).all() + loc_data = [{'locationid': loc.locationid, 'locationname': loc.locationname} for loc in locations] + + # Get subtypes for filter dropdowns + subtypes = {} + + try: + from plugins.machines.models import MachineType + machine_types = MachineType.query.filter(MachineType.isactive == True).order_by(MachineType.machinetype).all() + subtypes['Machine'] = [{'id': mt.machinetypeid, 'name': mt.machinetype, 'color': mt.color} for mt in machine_types] + except ImportError: + subtypes['Machine'] = [] + + try: + from plugins.computers.models import ComputerType + computer_types = ComputerType.query.filter(ComputerType.isactive == True).order_by(ComputerType.computertype).all() + subtypes['Computer'] = [{'id': ct.computertypeid, 'name': ct.computertype, 'color': ct.color} for ct in computer_types] + except ImportError: + subtypes['Computer'] = [] + + try: + from plugins.network.models import NetworkDeviceType + net_types = NetworkDeviceType.query.filter(NetworkDeviceType.isactive == True).order_by(NetworkDeviceType.networkdevicetype).all() + subtypes['Network Device'] = [{'id': nt.networkdevicetypeid, 'name': nt.networkdevicetype, 'color': nt.color} for nt in net_types] + except ImportError: + subtypes['Network Device'] = [] + + try: + from plugins.printers.models import PrinterType + printer_types = PrinterType.query.filter(PrinterType.isactive == True).order_by(PrinterType.printertype).all() + subtypes['Printer'] = [{'id': pt.printertypeid, 'name': pt.printertype, 'color': pt.color} for pt in printer_types] + except ImportError: + subtypes['Printer'] = [] + + try: + from plugins.measuringtools.models import MeasuringToolType + measuringtool_types = MeasuringToolType.query.filter( + MeasuringToolType.isactive == True).order_by(MeasuringToolType.name).all() + subtypes['Measuring Tool'] = [{'id': mt.measuringtooltypeid, 'name': mt.name, 'color': mt.color} for mt in measuringtool_types] + except ImportError: + subtypes['Measuring Tool'] = [] + + return success_response({ + 'assets': data, + 'total': len(data), + 'filters': { + 'assettypes': types_data, + 'statuses': status_data, + 'businessunits': bu_data, + 'locations': loc_data, + 'subtypes': subtypes + } + }) + + +@assets_bp.route('//communications', methods=['GET']) +@jwt_required(optional=True) +def get_asset_communications(asset_id: int): + """Get all communications for an asset.""" + from shopdb.core.models import Communication + + asset = db.session.get(Asset, asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + comms = Communication.query.filter_by( + assetid=asset_id, + isactive=True + ).all() + + data = [] + for comm in comms: + c = comm.to_dict() + c['comtype_name'] = comm.comtype.comtype if comm.comtype else None + data.append(c) + + return success_response(data) diff --git a/shopdb/core/api/auditlogs.py b/shopdb/core/api/auditlogs.py new file mode 100644 index 00000000..fca6ec06 --- /dev/null +++ b/shopdb/core/api/auditlogs.py @@ -0,0 +1,196 @@ +"""Audit log API routes.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.core.models import AuditLog +from shopdb.utils.responses import success_response +from shopdb.utils.authz import require_permission + +auditlogs_bp = Blueprint('auditlogs', __name__) + + +def _resolve_full_names(ssos): + """Best-effort SSO -> full name map for audit rows. Mode-aware, guarded, + degrades to {} so the list still renders if the directory is unreachable.""" + wanted = {s for s in ssos if s and str(s).isdigit()} + if not wanted: + return {} + names = {} + # Self-hosted directory first (no external dependency). + try: + from plugins.employees.models import DirectoryEmployee + rows = DirectoryEmployee.query.filter( + DirectoryEmployee.sso.in_(wanted)).all() + for emp in rows: + full = f'{(emp.firstname or "").strip()} {(emp.lastname or "").strip()}'.strip() + if full: + names[str(emp.sso)] = full + except Exception: + pass + missing = wanted - set(names) + if not missing: + return names + # External HR directory for anything still unresolved. + try: + from shopdb.utils.employee_db import employee_connection + conn = employee_connection() + placeholders = ','.join(['%s'] * len(missing)) + with conn.cursor() as cur: + cur.execute( + 'SELECT SSO, First_Name, Last_Name FROM employees ' + f'WHERE SSO IN ({placeholders})', tuple(missing)) + for emp in cur.fetchall(): + full = f'{(emp["First_Name"] or "").strip()} {(emp["Last_Name"] or "").strip()}'.strip() + if full: + names[str(emp['SSO'])] = full + conn.close() + except Exception: + pass + return names + + +@auditlogs_bp.route('', methods=['GET']) +@jwt_required() +@require_permission('audit.view') +def list_auditlogs(): + """ + List audit logs with filtering and pagination. + + Query params: + page: Page number (default 1) + perpage: Items per page (default 50, max 200) + action: Filter by action (created, updated, deleted) + entitytype: Filter by entity type + userid: Filter by user ID + search: Search in entityname or username + from_date: Filter from date (ISO format) + to_date: Filter to date (ISO format) + """ + page = request.args.get('page', 1, type=int) + perpage = min(request.args.get('perpage', 50, type=int), 200) + + query = AuditLog.query + + # Filters + action = request.args.get('action') + if action: + query = query.filter(AuditLog.action == action) + + entitytype = request.args.get('entitytype') + if entitytype: + query = query.filter(AuditLog.entitytype == entitytype) + + userid = request.args.get('userid', type=int) + if userid: + query = query.filter(AuditLog.userid == userid) + + search = request.args.get('search') + if search: + search_term = f'%{search}%' + query = query.filter( + (AuditLog.entityname.ilike(search_term)) | + (AuditLog.username.ilike(search_term)) + ) + + from_date = request.args.get('from_date') + if from_date: + from datetime import datetime + try: + dt = datetime.fromisoformat(from_date.replace('Z', '+00:00')) + query = query.filter(AuditLog.timestamp >= dt) + except ValueError: + pass + + to_date = request.args.get('to_date') + if to_date: + from datetime import datetime + try: + dt = datetime.fromisoformat(to_date.replace('Z', '+00:00')) + query = query.filter(AuditLog.timestamp <= dt) + except ValueError: + pass + + # Order by most recent first + query = query.order_by(AuditLog.timestamp.desc()) + + # Paginate + pagination = query.paginate(page=page, per_page=perpage, error_out=False) + + rows = [log.to_dict() for log in pagination.items] + # Attach full names (hover tooltip on the SSO); best-effort. + fullnames = _resolve_full_names({r.get('username') for r in rows}) + for r in rows: + r['userfullname'] = fullnames.get(str(r.get('username'))) + + return success_response( + rows, + meta={ + 'page': page, + 'perpage': perpage, + 'total': pagination.total, + 'pages': pagination.pages + } + ) + + +@auditlogs_bp.route('/entity//', methods=['GET']) +@jwt_required() +@require_permission('audit.view') +def get_entity_history(entitytype: str, entityid: int): + """Get audit history for a specific entity.""" + logs = AuditLog.query.filter_by( + entitytype=entitytype, + entityid=entityid + ).order_by(AuditLog.timestamp.desc()).all() + + return success_response([log.to_dict() for log in logs]) + + +@auditlogs_bp.route('/stats', methods=['GET']) +@jwt_required() +@require_permission('audit.view') +def get_stats(): + """Get audit log statistics.""" + from sqlalchemy import func + from datetime import datetime, timedelta, timezone + + # Actions by type + actions = db_func_count_by(AuditLog.action) + + # Entity types + entities = db_func_count_by(AuditLog.entitytype) + + # Recent activity (last 7 days) + week_ago = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=7) + recent_count = AuditLog.query.filter(AuditLog.timestamp >= week_ago).count() + + # Most active users (last 7 days) + from shopdb.extensions import db + active_users = db.session.query( + AuditLog.username, + func.count(AuditLog.auditlogid).label('count') + ).filter( + AuditLog.timestamp >= week_ago, + AuditLog.username.isnot(None) + ).group_by(AuditLog.username).order_by(func.count(AuditLog.auditlogid).desc()).limit(5).all() + + return success_response({ + 'actions': actions, + 'entities': entities, + 'recentCount': recent_count, + 'activeUsers': [{'username': u[0], 'count': u[1]} for u in active_users] + }) + + +def db_func_count_by(column): + """Helper to count grouped by a column.""" + from sqlalchemy import func + from shopdb.extensions import db + + results = db.session.query( + column, + func.count().label('count') + ).group_by(column).all() + + return {r[0]: r[1] for r in results} diff --git a/shopdb/core/api/auth.py b/shopdb/core/api/auth.py new file mode 100644 index 00000000..f3d36571 --- /dev/null +++ b/shopdb/core/api/auth.py @@ -0,0 +1,258 @@ +"""Authentication API endpoints.""" + +import time +from datetime import datetime, timedelta, timezone + +from flask import Blueprint, request, current_app +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + jwt_required, + get_jwt_identity, + current_user +) +from werkzeug.security import check_password_hash, generate_password_hash + +from shopdb.extensions import db, cache +from shopdb.core.models import User +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +auth_bp = Blueprint('auth', __name__) + +# Account lockout policy: after MAX_FAILED_LOGINS consecutive bad passwords, +# lock the account for LOCKOUT_MINUTES. Mitigates password brute-forcing. +MAX_FAILED_LOGINS = 5 +LOCKOUT_MINUTES = 15 + + +def _login_ip(): + """Caller IP for rate limiting, honoring the first X-Forwarded-For hop.""" + forwarded = request.headers.get('X-Forwarded-For') + if forwarded: + return forwarded.split(',')[0].strip() + return request.remote_addr or 'unknown' + + +def _login_ratelimited(): + """Fixed-window per-IP login limiter. Returns True when the caller is over + budget for the current window. + + Backed by the existing cache extension (no new dependency). Under the + default SimpleCache the counter is per-process, so with N gunicorn workers + the effective budget is N x AUTH_RATELIMIT_MAX. This is defense in depth + layered on top of the per-account lockout (see login()); a shared cache + backend (Redis/memcached) tightens it to a true global budget. + """ + if not current_app.config.get('AUTH_RATELIMIT_ENABLED', True): + return False + window = current_app.config.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300) + maxhits = current_app.config.get('AUTH_RATELIMIT_MAX', 30) + # Time bucket makes this a fixed window: the key rolls over at each window + # boundary, so a per-hit set() cannot turn it into a sliding window. + bucket = int(time.time() // window) if window > 0 else 0 + key = f'loginratelimit:{_login_ip()}:{bucket}' + count = cache.get(key) or 0 + if count >= maxhits: + return True + cache.set(key, count + 1, timeout=window) + return False + + +@auth_bp.route('/login', methods=['POST']) +def login(): + """ + Authenticate user and return JWT tokens. + + Request: + { + "username": "string", + "password": "string" + } + + Response: + { + "data": { + "access_token": "...", + "refresh_token": "...", + "user": {...} + } + } + """ + if _login_ratelimited(): + return error_response( + 'RATE_LIMITED', + 'Too many login attempts. Try again later.', + http_code=429 + ) + + data = request.get_json() + + if not data or not data.get('username') or not data.get('password'): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Username and password required' + ) + + user = User.query.filter_by( + username=data['username'], + isactive=True + ).first() + + # Reject a locked account before checking the password, so a lockout can't + # be probed and a valid password can't reset the clock mid-lockout. + if user and user.islocked: + return error_response( + ErrorCodes.FORBIDDEN, + 'Account is locked. Try again later or contact an administrator.', + http_code=403 + ) + + if not user or not check_password_hash(user.passwordhash, data['password']): + # Count the failure and lock the account once the threshold is hit. + # Only possible when the username matched a real account. + if user: + user.failedlogins = (user.failedlogins or 0) + 1 + if user.failedlogins >= MAX_FAILED_LOGINS: + # Naive UTC to match the naive lockeduntil column comparisons. + user.lockeduntil = datetime.now(timezone.utc).replace(tzinfo=None) \ + + timedelta(minutes=LOCKOUT_MINUTES) + user.failedlogins = 0 + db.session.commit() + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Invalid username or password', + http_code=401 + ) + + # Create tokens (identity must be a string in Flask-JWT-Extended 4.x) + access_token = create_access_token( + identity=str(user.userid), + additional_claims={ + 'username': user.username, + 'roles': [r.rolename for r in user.roles] + } + ) + refresh_token = create_refresh_token(identity=str(user.userid)) + + # Update last login and clear any failed-login state + user.lastlogindate = db.func.now() + user.failedlogins = 0 + user.lockeduntil = None + db.session.commit() + + return success_response({ + 'access_token': access_token, + 'refresh_token': refresh_token, + 'token_type': 'Bearer', + 'expires_in': 3600, + 'user': { + 'userid': user.userid, + 'username': user.username, + 'email': user.email, + 'firstname': user.firstname, + 'lastname': user.lastname, + 'roles': [r.rolename for r in user.roles], + 'mustchangepassword': bool(user.mustchangepassword) + } + }) + + +@auth_bp.route('/refresh', methods=['POST']) +@jwt_required(refresh=True) +def refresh(): + """Refresh access token using refresh token.""" + user_id = get_jwt_identity() + user = db.session.get(User, int(user_id)) + + if not user or not user.isactive: + return error_response( + ErrorCodes.UNAUTHORIZED, + 'User not found or inactive', + http_code=401 + ) + + access_token = create_access_token( + identity=str(user.userid), + additional_claims={ + 'username': user.username, + 'roles': [r.rolename for r in user.roles] + } + ) + + return success_response({ + 'access_token': access_token, + 'token_type': 'Bearer', + 'expires_in': 3600 + }) + + +@auth_bp.route('/me', methods=['GET']) +@jwt_required() +def get_current_user(): + """Get current authenticated user info.""" + return success_response({ + 'userid': current_user.userid, + 'username': current_user.username, + 'email': current_user.email, + 'firstname': current_user.firstname, + 'lastname': current_user.lastname, + 'roles': [r.rolename for r in current_user.roles], + 'permissions': current_user.getpermissions(), + 'mustchangepassword': bool(current_user.mustchangepassword) + }) + + +@auth_bp.route('/change-password', methods=['POST']) +@jwt_required() +def change_password(): + """Change the authenticated user's own password. + + Request: + { "current_password": "string", "new_password": "string" } + + current_password is required for a normal self-service change. When the + account is flagged mustchangepassword (an admin set a temporary password), + the forced-change case accepts new_password alone. On success the flag is + cleared and any lockout/failed-login state is reset. + """ + data = request.get_json() or {} + new_password = data.get('new_password') + current_password = data.get('current_password') + + if not new_password: + return error_response( + ErrorCodes.VALIDATION_ERROR, 'new_password is required') + if len(new_password) < 8: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'New password must be at least 8 characters') + + user = current_user + + # A normal change must prove knowledge of the current password. The forced + # first-login case (admin-set temp password) may skip it. + if not user.mustchangepassword: + if not current_password: + return error_response( + ErrorCodes.VALIDATION_ERROR, 'current_password is required') + if not check_password_hash(user.passwordhash, current_password): + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Current password is incorrect', http_code=401) + + user.passwordhash = generate_password_hash(new_password) + user.mustchangepassword = False + user.failedlogins = 0 + user.lockeduntil = None + db.session.commit() + + return success_response( + {'mustchangepassword': False}, message='Password changed') + + +@auth_bp.route('/logout', methods=['POST']) +@jwt_required() +def logout(): + """Logout user (for frontend token cleanup).""" + # In a full implementation, you'd blacklist the token + return success_response(message='Successfully logged out') diff --git a/shopdb/core/api/businessunits.py b/shopdb/core/api/businessunits.py new file mode 100644 index 00000000..6baf9b00 --- /dev/null +++ b/shopdb/core/api/businessunits.py @@ -0,0 +1,156 @@ +"""Business Units API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import BusinessUnit +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from shopdb.utils.authz import require_role +from shopdb.utils.import_mode import apply_import_timestamps + +businessunits_bp = Blueprint('businessunits', __name__) + + +@businessunits_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_businessunits(): + """List all business units.""" + page, per_page = get_pagination_params(request) + + query = BusinessUnit.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(BusinessUnit.isactive == True) + + # Exact-match natural-key lookup for idempotent import (unit name). + if exactunit := request.args.get('businessunit'): + query = query.filter(BusinessUnit.businessunit == exactunit) + + if search := request.args.get('search'): + query = query.filter( + db.or_( + BusinessUnit.businessunit.ilike(f'%{search}%'), + BusinessUnit.code.ilike(f'%{search}%') + ) + ) + + query = query.order_by(BusinessUnit.businessunit) + + items, total = paginate_query(query, page, per_page) + data = [bu.to_dict() for bu in items] + + return paginated_response(data, page, per_page, total) + + +@businessunits_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_businessunit(bu_id: int): + """Get a single business unit.""" + bu = db.session.get(BusinessUnit, bu_id) + + if not bu: + return error_response( + ErrorCodes.NOT_FOUND, + f'Business unit with ID {bu_id} not found', + http_code=404 + ) + + data = bu.to_dict() + data['parent'] = bu.parent.to_dict() if bu.parent else None + data['children'] = [c.to_dict() for c in bu.children] + + return success_response(data) + + +@businessunits_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_businessunit(): + """Create a new business unit.""" + data = request.get_json() + + if not data or not data.get('businessunit'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'businessunit is required') + + if BusinessUnit.query.filter_by(businessunit=data['businessunit']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Business unit '{data['businessunit']}' already exists", + http_code=409 + ) + + bu = BusinessUnit( + businessunit=data['businessunit'], + code=data.get('code'), + description=data.get('description'), + parentid=data.get('parentid') + ) + + db.session.add(bu) + apply_import_timestamps(bu, data) + db.session.commit() + + return success_response(bu.to_dict(), message='Business unit created', http_code=201) + + +@businessunits_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_businessunit(bu_id: int): + """Update a business unit.""" + bu = db.session.get(BusinessUnit, bu_id) + + if not bu: + return error_response( + ErrorCodes.NOT_FOUND, + f'Business unit with ID {bu_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'businessunit' in data and data['businessunit'] != bu.businessunit: + if BusinessUnit.query.filter_by(businessunit=data['businessunit']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Business unit '{data['businessunit']}' already exists", + http_code=409 + ) + + for key in ['businessunit', 'code', 'description', 'parentid', 'isactive']: + if key in data: + setattr(bu, key, data[key]) + + apply_import_timestamps(bu, data) + db.session.commit() + return success_response(bu.to_dict(), message='Business unit updated') + + +@businessunits_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_businessunit(bu_id: int): + """Delete (deactivate) a business unit.""" + bu = db.session.get(BusinessUnit, bu_id) + + if not bu: + return error_response( + ErrorCodes.NOT_FOUND, + f'Business unit with ID {bu_id} not found', + http_code=404 + ) + + bu.isactive = False + db.session.commit() + + return success_response(message='Business unit deleted') diff --git a/shopdb/core/api/collector.py b/shopdb/core/api/collector.py new file mode 100644 index 00000000..1b0bd654 --- /dev/null +++ b/shopdb/core/api/collector.py @@ -0,0 +1,486 @@ +""" +PowerShell data-collection API endpoints. + +Compatibility layer for the PowerShell scripts that report PC state. Uses an +API key (not JWT) for unattended scripts. Writes the asset/computer model +(ADR-001), not the retired Machine model. +""" + +from datetime import datetime, timezone +from functools import wraps +from flask import Blueprint, request, current_app, g + +from shopdb.extensions import db +from shopdb.core.models import Asset, Application +from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX + +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +collector_bp = Blueprint('collector', __name__) + +# A managed token scoped to this permission is a collector service token: it +# authorizes the collector ingest API and nothing else (ADR-006, machine +# identity - no require_permission on these routes). +COLLECTOR_SCOPE = 'collector.ingest' + + +def _computer_models(): + """Lazily import the computers plugin models. + + The legacy /pc, /apps, /heartbeat, /bulk endpoints predate the generic + collector contract and are computers-specific. Importing the plugin lazily + (instead of at module load) keeps core importable when the computers plugin + is absent or disabled. Returns (Computer, ComputerInstalledApp) or None. + """ + try: + from plugins.computers.models import Computer, ComputerInstalledApp + return Computer, ComputerInstalledApp + except ImportError: + return None + + +def _token_is_collector(token): + """True when this managed token is a collector service token: scoped for + collector.ingest AND its owner actually holds that permission (admins do).""" + from shopdb.core.models import User + + scopelist = token.scopelist + if not scopelist or COLLECTOR_SCOPE not in scopelist: + return False + user = db.session.get(User, token.userid) + return (user is not None and user.isactive + and user.haspermission(COLLECTOR_SCOPE)) + + +def _collector_managed_token(api_key): + """Find the managed token backing this collector request, or None. + + Two transports are accepted (GE-Enforce sends X-API-Key today; Bearer is + the standard PAT wire form): + X-API-Key: the before_request PAT shim ignores this header, so resolve the + secret here. + Bearer: the shim already resolved+swapped it (the raw Bearer secret is + gone by now), leaving g.apitokenid. Reuse that. + """ + from shopdb.utils.apitoken_auth import resolve_api_token + + if api_key and api_key.startswith(TOKEN_SECRET_PREFIX): + resolved = resolve_api_token(api_key) + return resolved[0] if resolved else None + tokenid = getattr(g, 'apitokenid', None) + if tokenid is not None: + return db.session.get(ApiToken, tokenid) + return None + + +def _check_collector_auth(expected_key): + """Authorize a collector request. Returns None when authorized, else the + error_response to return. + + Accepts a collector-scoped managed token (Bearer or X-API-Key) OR the env + key via X-API-Key (legacy/bootstrap fallback). Env keys stay supported so + nothing breaks; a valid managed token works even with no env key set. + """ + from shopdb.utils.apitoken_auth import touch_apitoken_lastused + + api_key = request.headers.get('X-API-Key') + + token = _collector_managed_token(api_key) + if token is not None and _token_is_collector(token): + touch_apitoken_lastused(token) + return None + + if expected_key and api_key == expected_key: + return None + + # Fail-closed only on pure server misconfiguration: no env key AND the + # caller presented no managed token at all. Otherwise it is a bad credential. + presented_pat = token is not None or ( + api_key is not None and api_key.startswith(TOKEN_SECRET_PREFIX)) + if not expected_key and not presented_pat: + return error_response(ErrorCodes.INTERNAL_ERROR, + 'Collector API key not configured', http_code=500) + return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key', + http_code=401) + + +def require_api_key(f): + """Require collector API-key OR collector-scoped managed-token auth.""" + @wraps(f) + def decorated(*args, **kwargs): + # Header only. Querystring api_key was dropped so keys do not land in + # access logs / proxy history (breaking change, see COLLECTOR-INTEGRATION.md). + error = _check_collector_auth(current_app.config.get('COLLECTOR_API_KEY')) + if error is not None: + return error + return f(*args, **kwargs) + return decorated + + +def _find_pc(hostname): + """Find a computer by hostname, falling back to its asset number. + + Returns None if not found or if the computers plugin is unavailable; + callers already treat None as a 404. + """ + models = _computer_models() + if not models: + return None + Computer, _ = models + comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first() + if comp: + return comp + return ( + Computer.query.join(Asset, Asset.assetid == Computer.assetid) + .filter(Asset.assetnumber.ilike(hostname)) + .first() + ) + + +def _parse_boot(value): + try: + return datetime.fromisoformat(value.replace('Z', '+00:00')) + except (ValueError, AttributeError): + return None + + +def _plugin_api_key(pluginname): + """Per-plugin collector key, falling back to the shared key (ADR-006). + + Looks up COLLECTOR_API_KEY_ (uppercased) first so each + collector can carry its own credential, then COLLECTOR_API_KEY. + """ + per_plugin = current_app.config.get( + f'COLLECTOR_API_KEY_{pluginname.upper()}') + return per_plugin or current_app.config.get('COLLECTOR_API_KEY') + + +def _collector_plugins(): + """Map of pluginname -> (plugin, schema) for plugins accepting collector input.""" + pm = current_app.extensions.get('plugin_manager') + result = {} + if not pm: + return result + for name, plugin in pm.get_all_plugins().items(): + if not pm.registry.is_enabled(name): + continue + try: + schema = plugin.get_collector_schema() + except Exception: + # Fail loud in dev/test so a broken hook is visible; isolate the + # misbehaving plugin in prod and keep serving the healthy ones. + if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): + raise + current_app.logger.exception( + 'Plugin %s get_collector_schema failed', name) + continue + if schema: + result[name] = (plugin, schema) + return result + + +@collector_bp.route('/', methods=['POST']) +def generic_collect(pluginname): + """Generic collector ingest for any plugin (ADR-006). + + Per-plugin API key auth, schema-driven validation of the identity field, + idempotent upsert via the plugin's apply_collector_payload. Returns the + ADR-006 response contract: status, action, assetid, identityvalue, warnings. + """ + from shopdb.core.models import AuditLog + + plugins = _collector_plugins() + if pluginname not in plugins: + return error_response( + ErrorCodes.NOT_FOUND, + f'No collector registered for plugin {pluginname}', + http_code=404) + + plugin, schema = plugins[pluginname] + + # Per-plugin env key (fallback) OR a collector-scoped managed token. Header + # only (querystring fallback dropped, see require_api_key). + error = _check_collector_auth(_plugin_api_key(pluginname)) + if error is not None: + return error + + payload = request.get_json(silent=True) + if not payload or not isinstance(payload, dict): + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + identityfield = schema.get('identityfield') + identityvalue = (payload.get(identityfield) or '').strip() if identityfield else '' + if identityfield and not identityvalue: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'{identityfield} is required') + + try: + outcome = plugin.apply_collector_payload(payload) + except NotImplementedError: + return error_response( + ErrorCodes.INTERNAL_ERROR, + f'Plugin {pluginname} does not implement apply_collector_payload', + http_code=500) + except ValueError as exc: + # ValueError is the plugin's controlled validation signal; its message + # is safe to return to the caller. + db.session.rollback() + return error_response(ErrorCodes.VALIDATION_ERROR, str(exc)) + except Exception: + # Do not leak internals (stack detail, DB errors) to the caller; log it. + db.session.rollback() + current_app.logger.exception('Collector upsert failed for %s', pluginname) + return error_response(ErrorCodes.INTERNAL_ERROR, + 'Internal error processing collector payload', + http_code=500) + + action = outcome.get('action', 'noop') + AuditLog.log( + action if action in ('created', 'updated') else 'updated', + 'Collector', + entityid=outcome.get('assetid'), + entityname=outcome.get('identityvalue', identityvalue), + details=f'collector:{pluginname} action={action}', + ) + db.session.commit() + + response = { + 'status': 'ok', + 'action': action, + 'assetid': outcome.get('assetid'), + 'identityvalue': outcome.get('identityvalue', identityvalue), + 'warnings': outcome.get('warnings', []), + } + # Generic passthrough: a plugin may surface extra result fields (e.g. the + # computers plugin returns printerlinks). Known keys win over extras. + for key, value in (outcome.get('extra') or {}).items(): + response.setdefault(key, value) + return success_response(response, message=f'{pluginname} collector {action}') + + +@collector_bp.route('/_schemas', methods=['GET']) +def collector_schemas(): + """List collector schemas for all enabled plugins (JWT-protected).""" + from flask_jwt_extended import verify_jwt_in_request + verify_jwt_in_request() + + schemas = { + name: schema for name, (plugin, schema) in _collector_plugins().items() + } + return success_response({'schemas': schemas}) + + +@collector_bp.route('/pc', methods=['POST']) +@require_api_key +def update_pc_info(): + """Update one PC from the collection script (matched by hostname).""" + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + hostname = data.get('hostname') + if not hostname: + return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required') + + comp = _find_pc(hostname) + if not comp: + return error_response(ErrorCodes.NOT_FOUND, + f'PC with hostname {hostname} not found', + http_code=404) + + comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None) + + if data.get('lastboottime'): + boot = _parse_boot(data['lastboottime']) + if boot: + comp.lastboottime = boot + if data.get('currentuser'): + comp.loggedinuser = data['currentuser'] + if data.get('serialnumber') and comp.asset: + comp.asset.serialnumber = data['serialnumber'] + + db.session.commit() + + return success_response({ + 'computerid': comp.computerid, + 'hostname': comp.hostname, + 'updated': True + }, message='PC info updated') + + +@collector_bp.route('/apps', methods=['POST']) +@require_api_key +def update_installed_apps(): + """Update installed applications for a PC (matched by hostname).""" + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + hostname = data.get('hostname') + if not hostname: + return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required') + + apps = data.get('apps', []) + if not apps: + return error_response(ErrorCodes.VALIDATION_ERROR, 'apps list is required') + + comp = _find_pc(hostname) + if not comp: + return error_response(ErrorCodes.NOT_FOUND, + f'PC with hostname {hostname} not found', + http_code=404) + + # comp existing implies the computers plugin is loaded. + _, ComputerInstalledApp = _computer_models() + + updated_count = created_count = skipped_count = 0 + + for app_data in apps: + app_name = app_data.get('appname') + if not app_name: + skipped_count += 1 + continue + + app = Application.query.filter(Application.appname.ilike(app_name)).first() + if not app: + # Only track applications we manage + skipped_count += 1 + continue + + version = app_data.get('version') + installed = ComputerInstalledApp.query.filter_by( + computerid=comp.computerid, appid=app.appid).first() + + if installed: + changed = False + if version and installed.installedversion != version: + installed.installedversion = version + changed = True + if not installed.isactive: + installed.isactive = True + changed = True + if changed: + updated_count += 1 + else: + db.session.add(ComputerInstalledApp( + computerid=comp.computerid, + appid=app.appid, + installedversion=version, + )) + created_count += 1 + + db.session.commit() + + return success_response({ + 'hostname': hostname, + 'computerid': comp.computerid, + 'created': created_count, + 'updated': updated_count, + 'skipped': skipped_count + }, message='Installed apps updated') + + +@collector_bp.route('/heartbeat', methods=['POST']) +@require_api_key +def pc_heartbeat(): + """Record PC online status / heartbeat (single or batch).""" + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + hostnames = data.get('hostnames', []) + if not hostnames and data.get('hostname'): + hostnames = [data['hostname']] + if not hostnames: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'hostname or hostnames required') + + updated = 0 + not_found = [] + now = datetime.now(timezone.utc).replace(tzinfo=None) + for hostname in hostnames: + comp = _find_pc(hostname) + if comp: + comp.lastreporteddate = now + updated += 1 + else: + not_found.append(hostname) + + db.session.commit() + + return success_response({ + 'updated': updated, + 'notfound': not_found, + 'timestamp': now.isoformat() + }, message=f'{updated} PC(s) heartbeat recorded') + + +@collector_bp.route('/bulk', methods=['POST']) +@require_api_key +def bulk_update(): + """Bulk update multiple PCs at once.""" + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + pcs = data.get('pcs', []) + if not pcs: + return error_response(ErrorCodes.VALIDATION_ERROR, 'pcs list is required') + + updated = 0 + not_found = [] + errors = [] + now = datetime.now(timezone.utc).replace(tzinfo=None) + + for pc_data in pcs: + hostname = pc_data.get('hostname') + if not hostname: + continue + + comp = _find_pc(hostname) + if not comp: + not_found.append(hostname) + continue + + try: + comp.lastreporteddate = now + if pc_data.get('currentuser'): + comp.loggedinuser = pc_data['currentuser'] + if pc_data.get('lastboottime'): + boot = _parse_boot(pc_data['lastboottime']) + if boot: + comp.lastboottime = boot + updated += 1 + except Exception: + # Keep the hostname so the caller knows which PC failed, but do not + # leak the exception detail; log it server-side. + current_app.logger.exception('Bulk update failed for %s', hostname) + errors.append({'hostname': hostname, 'error': 'processing failed'}) + + db.session.commit() + + return success_response({ + 'updated': updated, + 'notfound': not_found, + 'errors': errors, + 'timestamp': now.isoformat() + }, message=f'{updated} PC(s) updated') + + +@collector_bp.route('/status', methods=['GET']) +@require_api_key +def collector_status(): + """Check collector API status.""" + return success_response({ + 'status': 'ok', + 'timestamp': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'endpoints': [ + 'POST /api/collector/', + 'GET /api/collector/_schemas', + 'POST /api/collector/pc', + 'POST /api/collector/apps', + 'POST /api/collector/heartbeat', + 'POST /api/collector/bulk', + 'GET /api/collector/status' + ] + }) diff --git a/shopdb/core/api/customfields.py b/shopdb/core/api/customfields.py new file mode 100644 index 00000000..3032bf0e --- /dev/null +++ b/shopdb/core/api/customfields.py @@ -0,0 +1,218 @@ +"""Custom fields API - definitions per asset type + per-asset values.""" + +import json +import re + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import CustomField, CustomFieldValue, Asset, AssetType +from shopdb.core.models.customfield import CUSTOM_FIELD_DATATYPES +from shopdb.utils.responses import ( + success_response, + error_response, + ErrorCodes +) +from shopdb.utils.authz import require_role + +customfields_bp = Blueprint('customfields', __name__) + + +def _slugify_key(label): + """Build a stable machine key from a label: lowercase, alnum only.""" + key = re.sub(r'[^a-z0-9]', '', (label or '').lower()) + return key[:50] or 'field' + + +def _normalize_options(raw): + """Return a JSON string of a clean list of option strings, or None.""" + if not raw: + return None + if isinstance(raw, str): + # Accept newline- or comma-separated text too. + parts = re.split(r'[\n,]', raw) + elif isinstance(raw, (list, tuple)): + parts = raw + else: + return None + cleaned = [str(p).strip() for p in parts if str(p).strip()] + return json.dumps(cleaned) if cleaned else None + + +# ============================================================================= +# Field definitions +# ============================================================================= + +@customfields_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_fields(): + """List custom-field definitions. Filter by ?assettypeid=. ?active=false + includes inactive ones.""" + query = CustomField.query + assettypeid = request.args.get('assettypeid', type=int) + if assettypeid: + query = query.filter_by(assettypeid=assettypeid) + if request.args.get('active', 'true').lower() != 'false': + query = query.filter_by(isactive=True) + fields = query.order_by(CustomField.sortorder, CustomField.fieldid).all() + return success_response([f.to_dict() for f in fields]) + + +@customfields_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_field(): + data = request.get_json() or {} + assettypeid = data.get('assettypeid') + label = (data.get('label') or '').strip() + if not assettypeid or not label: + return error_response(ErrorCodes.VALIDATION_ERROR, 'assettypeid and label are required') + if not db.session.get(AssetType, assettypeid): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown assettypeid') + + datatype = data.get('datatype') or 'text' + if datatype not in CUSTOM_FIELD_DATATYPES: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'datatype must be one of {", ".join(CUSTOM_FIELD_DATATYPES)}') + + fieldkey = (data.get('fieldkey') or '').strip() or _slugify_key(label) + # Reactivate a same-key field that was soft-deleted, instead of colliding. + existing = CustomField.query.filter_by(assettypeid=assettypeid, fieldkey=fieldkey).first() + if existing: + if not existing.isactive: + existing.isactive = True + existing.label = label + existing.datatype = datatype + existing.options = _normalize_options(data.get('options')) + existing.showondetail = bool(data.get('showondetail', True)) + existing.showonform = bool(data.get('showonform', True)) + existing.searchable = bool(data.get('searchable', False)) + existing.sortorder = data.get('sortorder', 0) + db.session.commit() + return success_response(existing.to_dict(), message='Reactivated existing field') + return error_response(ErrorCodes.CONFLICT, + f'A field with key "{fieldkey}" already exists for this asset type') + + field = CustomField( + assettypeid=assettypeid, + fieldkey=fieldkey, + label=label, + datatype=datatype, + options=_normalize_options(data.get('options')), + showondetail=bool(data.get('showondetail', True)), + showonform=bool(data.get('showonform', True)), + searchable=bool(data.get('searchable', False)), + sortorder=data.get('sortorder', 0), + ) + db.session.add(field) + db.session.commit() + return success_response(field.to_dict(), message='Custom field created', http_code=201) + + +@customfields_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_field(fieldid): + field = db.session.get(CustomField, fieldid) + if not field: + return error_response(ErrorCodes.NOT_FOUND, 'Custom field not found', http_code=404) + data = request.get_json() or {} + if 'label' in data: + field.label = (data['label'] or '').strip() or field.label + if 'datatype' in data: + if data['datatype'] not in CUSTOM_FIELD_DATATYPES: + return error_response(ErrorCodes.VALIDATION_ERROR, 'invalid datatype') + field.datatype = data['datatype'] + if 'options' in data: + field.options = _normalize_options(data['options']) + for key in ('showondetail', 'showonform', 'isactive', 'searchable'): + if key in data: + setattr(field, key, bool(data[key])) + if 'sortorder' in data: + field.sortorder = data['sortorder'] + db.session.commit() + return success_response(field.to_dict(), message='Custom field updated') + + +@customfields_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_field(fieldid): + field = db.session.get(CustomField, fieldid) + if not field: + return error_response(ErrorCodes.NOT_FOUND, 'Custom field not found', http_code=404) + # Drop the field and any stored values for it. + CustomFieldValue.query.filter_by(fieldid=fieldid).delete() + db.session.delete(field) + db.session.commit() + return success_response(message='Custom field deleted') + + +# ============================================================================= +# Per-asset values +# ============================================================================= + +def _fields_with_values(asset): + """Return active field defs for this asset's type, each with the asset's + stored value merged in.""" + fields = (CustomField.query + .filter_by(assettypeid=asset.assettypeid, isactive=True) + .order_by(CustomField.sortorder, CustomField.fieldid) + .all()) + values = {v.fieldid: v.value for v in + CustomFieldValue.query.filter_by(assetid=asset.assetid).all()} + result = [] + for f in fields: + item = f.to_dict() + item['value'] = values.get(f.fieldid) + result.append(item) + return result + + +@customfields_bp.route('/asset/', methods=['GET']) +@jwt_required(optional=True) +def get_asset_fields(assetid): + """Active custom fields for an asset's type, merged with its values.""" + asset = db.session.get(Asset, assetid) + if not asset: + return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404) + return success_response(_fields_with_values(asset)) + + +@customfields_bp.route('/asset/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def save_asset_fields(assetid): + """Upsert values for an asset. Body: {values: {fieldid: value, ...}}.""" + asset = db.session.get(Asset, assetid) + if not asset: + return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404) + data = request.get_json() or {} + values = data.get('values') or {} + + # Only accept fields that belong to this asset's type. + valid_ids = {f.fieldid for f in + CustomField.query.filter_by(assettypeid=asset.assettypeid).all()} + + for raw_fieldid, raw_value in values.items(): + try: + fieldid = int(raw_fieldid) + except (ValueError, TypeError): + continue + if fieldid not in valid_ids: + continue + value = '' if raw_value is None else str(raw_value) + row = CustomFieldValue.query.filter_by(fieldid=fieldid, assetid=assetid).first() + if value == '': + # Empty clears the stored value. + if row: + db.session.delete(row) + continue + if row: + row.value = value + else: + db.session.add(CustomFieldValue(fieldid=fieldid, assetid=assetid, value=value)) + + db.session.commit() + return success_response(_fields_with_values(asset), message='Custom fields saved') diff --git a/shopdb/core/api/dashboard.py b/shopdb/core/api/dashboard.py new file mode 100644 index 00000000..44bc2dd6 --- /dev/null +++ b/shopdb/core/api/dashboard.py @@ -0,0 +1,216 @@ +"""Dashboard API endpoints.""" + +from flask import Blueprint, current_app +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Asset, AssetType, AssetStatus +from shopdb.utils.responses import success_response + +dashboard_bp = Blueprint('dashboard', __name__) + +# Map asset type name -> dashboard category label +_TYPE_CATEGORY = { + 'machine': 'Machine', + 'computer': 'PC', + 'printer': 'Printer', + 'network_device': 'Network', + 'measuring_tool': 'Measuring Tool', +} + + +def _count_by_type(assettype): + query = db.session.query(Asset).join(AssetType).filter( + Asset.isactive == True, + AssetType.assettype == assettype + ) + # Dualpath single-machine collapse: subtract the hidden secondary bays so a + # dual-bay machine counts once. Only machines are ever Dualpath-paired, but + # the AssetType filter above keeps this correct for any type. Default on. + if assettype == 'machine': + from shopdb.core.services.dualpath import ( + resolve_dualpath_pairs, dualpath_single_machine_enabled) + if dualpath_single_machine_enabled(): + secondaryassetids = resolve_dualpath_pairs().secondaryassetids + if secondaryassetids: + query = query.filter(Asset.assetid.notin_(secondaryassetids)) + return query.count() + + +@dashboard_bp.route('/summary', methods=['GET']) +@dashboard_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def get_dashboard(): + """Get dashboard summary data (asset-based).""" + machine_count = _count_by_type('machine') + pc_count = _count_by_type('computer') + network_count = _count_by_type('network_device') + printer_count = _count_by_type('printer') + measuringtool_count = _count_by_type('measuring_tool') + total = machine_count + pc_count + network_count + printer_count + measuringtool_count + + # Count by status + status_counts = db.session.query( + AssetStatus.status, + db.func.count(Asset.assetid) + ).outerjoin( + Asset, + db.and_(Asset.statusid == AssetStatus.statusid, Asset.isactive == True) + ).group_by(AssetStatus.status).all() + status_dict = {status: count for status, count in status_counts} + + # Recent assets + recent = Asset.query.filter_by(isactive=True).order_by( + Asset.createddate.desc() + ).limit(10).all() + + return success_response({ + # Fields expected by frontend (totalmachines now means machines, + # totalassets is the grand total - renamed with the machines plugin) + 'totalassets': total, + 'totalmachines': machine_count, + 'totalpc': pc_count, + 'totalnetwork': network_count, + 'totalprinter': printer_count, + 'totalmeasuringtool': measuringtool_count, + 'activeassets': status_dict.get('In Use', 0), + 'inrepair': status_dict.get('In Repair', 0), + # Structured data + 'counts': { + 'machines': machine_count, + 'pcs': pc_count, + 'networkdevices': network_count, + 'printers': printer_count, + 'measuringtools': measuringtool_count, + 'total': total + }, + 'bystatus': status_dict, + 'recent': [ + { + 'assetid': a.assetid, + 'assetnumber': a.assetnumber, + 'assettype': a.assettype.assettype if a.assettype else None, + 'createddate': a.createddate.isoformat() + 'Z' if a.createddate else None + } + for a in recent + ] + }) + + +@dashboard_bp.route('/stats', methods=['GET']) +@jwt_required(optional=True) +def get_stats(): + """Get detailed statistics by asset type.""" + type_counts = db.session.query( + AssetType.assettype, + db.func.count(Asset.assetid) + ).outerjoin( + Asset, + db.and_(Asset.assettypeid == AssetType.assettypeid, Asset.isactive == True) + ).group_by(AssetType.assettypeid).all() + + return success_response({ + 'bytype': [ + { + 'type': t, + 'category': _TYPE_CATEGORY.get(t, t), + 'count': count + } + for t, count in type_counts + ] + }) + + +@dashboard_bp.route('/navigation', methods=['GET']) +def get_navigation(): + """Get navigation items from all loaded plugins.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + return success_response([]) + + all_items = [] + + # Core navigation items (always present) + all_items.extend([ + {'name': 'Dashboard', 'icon': 'layout-dashboard', 'route': '/', 'position': 0}, + {'name': 'Map', 'icon': 'map', 'route': '/map', 'position': 4}, + ]) + + # Collect navigation items from enabled plugins. Disabling persists to the + # registry immediately, so a disabled plugin drops out of the menu right + # away (its routes stay registered until the next restart - Flask cannot + # unregister a blueprint at runtime). + for name, plugin in pm.get_all_plugins().items(): + if not pm.registry.is_enabled(name): + continue + try: + items = plugin.get_navigation_items() + for item in items: + item['plugin'] = name + all_items.extend(items) + except Exception: + # Fail loud in dev/test; isolate a broken plugin in prod. + if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): + raise + current_app.logger.exception( + 'Plugin %s get_navigation_items failed', name) + + # Add core information section items + all_items.extend([ + {'name': 'Applications', 'icon': 'app-window', 'route': '/applications', 'position': 30, 'section': 'information'}, + {'name': 'Reports', 'icon': 'bar-chart-3', 'route': '/reports', 'position': 40, 'section': 'information'}, + ]) + + # Sort by position + all_items.sort(key=lambda x: x.get('position', 99)) + + return success_response(all_items) + + +@dashboard_bp.route('/widgets', methods=['GET']) +@jwt_required(optional=True) +def get_widgets(): + """Aggregate dashboard widget definitions from all enabled plugins. + + Consumer for the BasePlugin.get_dashboard_widgets hook. Skips disabled + plugins, isolates a broken plugin in prod (re-raises in dev/test), and + returns the merged list sorted by position. + """ + pm = current_app.extensions.get('plugin_manager') + if not pm: + return success_response([]) + + widgets = [] + for name, plugin in pm.get_all_plugins().items(): + if not pm.registry.is_enabled(name): + continue + try: + for widget in plugin.get_dashboard_widgets() or []: + widget['plugin'] = name + widgets.append(widget) + except Exception: + if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): + raise + current_app.logger.exception( + 'Plugin %s get_dashboard_widgets failed', name) + + widgets.sort(key=lambda w: w.get('position', 99)) + return success_response(widgets) + + +@dashboard_bp.route('/health', methods=['GET']) +def health_check(): + """Health check endpoint (no auth required).""" + from shopdb import __version__ + + try: + db.session.execute(db.text('SELECT 1')) + db_status = 'healthy' + except Exception as e: + db_status = f'unhealthy: {str(e)}' + + return success_response({ + 'status': 'ok' if db_status == 'healthy' else 'degraded', + 'database': db_status, + 'version': __version__ + }) diff --git a/shopdb/core/api/dashboarddefaults.py b/shopdb/core/api/dashboarddefaults.py new file mode 100644 index 00000000..23fdc6b0 --- /dev/null +++ b/shopdb/core/api/dashboarddefaults.py @@ -0,0 +1,131 @@ +"""Dashboard defaults API: visitor-IP -> business-unit mapping. + +CRUD for the mappings plus a resolve endpoint a kiosk/lobby dashboard calls to +auto-select its business unit from the display PC's IP. +""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import DashboardDefault, BusinessUnit, AuditLog +from shopdb.utils.responses import success_response, error_response, ErrorCodes +from shopdb.utils.authz import require_role + +dashboarddefaults_bp = Blueprint('dashboarddefaults', __name__) + + +def _request_ip(): + """Caller IP, honoring a single proxy hop via X-Forwarded-For.""" + forwarded = request.headers.get('X-Forwarded-For') + if forwarded: + return forwarded.split(',')[0].strip() + return request.remote_addr + + +def _serialize(default): + data = default.to_dict() + data['businessunit'] = default.businessunit.businessunit \ + if default.businessunit else None + return data + + +@dashboarddefaults_bp.route('/visitor-location', methods=['GET']) +def visitor_location(): + """Resolve the business unit for the calling display by its IP. + + Unauthenticated: kiosks/lobby displays hit this. Returns the mapped + business unit, or a null businessunitid when the IP is not mapped. + """ + ipaddress = request.args.get('ipaddress') or _request_ip() + default = DashboardDefault.query.filter_by( + ipaddress=ipaddress, isactive=True).first() + if not default: + return success_response({ + 'ipaddress': ipaddress, + 'businessunitid': None, + 'businessunit': None, + }) + return success_response({ + 'ipaddress': ipaddress, + 'businessunitid': default.businessunitid, + 'businessunit': default.businessunit.businessunit + if default.businessunit else None, + }) + + +@dashboarddefaults_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_defaults(): + """List all visitor-IP -> business-unit mappings.""" + defaults = DashboardDefault.query.filter_by(isactive=True).order_by( + DashboardDefault.ipaddress).all() + return success_response([_serialize(d) for d in defaults]) + + +@dashboarddefaults_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_default(): + """Create a visitor-IP -> business-unit mapping.""" + data = request.get_json() or {} + ipaddress = (data.get('ipaddress') or '').strip() + businessunitid = data.get('businessunitid') + + if not ipaddress: + return error_response(ErrorCodes.VALIDATION_ERROR, 'ipaddress is required') + if not businessunitid: + return error_response(ErrorCodes.VALIDATION_ERROR, 'businessunitid is required') + if not db.session.get(BusinessUnit, businessunitid): + return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found', + http_code=404) + if DashboardDefault.query.filter_by(ipaddress=ipaddress, isactive=True).first(): + return error_response(ErrorCodes.CONFLICT, + f"IP {ipaddress} is already mapped", http_code=409) + + default = DashboardDefault(ipaddress=ipaddress, businessunitid=businessunitid, + description=data.get('description')) + db.session.add(default) + db.session.flush() + AuditLog.log('created', 'DashboardDefault', entityid=default.dashboarddefaultid, + entityname=ipaddress) + db.session.commit() + return success_response(_serialize(default), message='Mapping created', + http_code=201) + + +@dashboarddefaults_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_default(default_id): + """Update a mapping.""" + default = db.session.get(DashboardDefault, default_id) + if not default: + return error_response(ErrorCodes.NOT_FOUND, 'Mapping not found', http_code=404) + + data = request.get_json() or {} + if 'businessunitid' in data: + if not db.session.get(BusinessUnit, data['businessunitid']): + return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found', + http_code=404) + default.businessunitid = data['businessunitid'] + if 'ipaddress' in data and data['ipaddress']: + default.ipaddress = data['ipaddress'].strip() + if 'description' in data: + default.description = data['description'] + + db.session.commit() + return success_response(_serialize(default), message='Mapping updated') + + +@dashboarddefaults_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_default(default_id): + """Delete (deactivate) a mapping.""" + default = db.session.get(DashboardDefault, default_id) + if not default: + return error_response(ErrorCodes.NOT_FOUND, 'Mapping not found', http_code=404) + default.isactive = False + db.session.commit() + return success_response(message='Mapping deleted') diff --git a/shopdb/core/api/locations.py b/shopdb/core/api/locations.py new file mode 100644 index 00000000..dd4cf56b --- /dev/null +++ b/shopdb/core/api/locations.py @@ -0,0 +1,241 @@ +"""Locations API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Location, LocationType +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from shopdb.utils.authz import require_role +from shopdb.utils.import_mode import apply_import_timestamps + +locations_bp = Blueprint('locations', __name__) + + +def _loc_type_dict(t): + return { + 'locationtypeid': t.locationtypeid, + 'locationtype': t.locationtype, + 'description': t.description, + 'color': t.color, + 'isactive': t.isactive, + } + + +@locations_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_location_types(): + """List location types. ?active=false includes inactive ones.""" + query = LocationType.query + if request.args.get('active', 'true').lower() != 'false': + query = query.filter_by(isactive=True) + types = query.order_by(LocationType.locationtype).all() + return success_response([_loc_type_dict(t) for t in types]) + + +@locations_bp.route('/types', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_location_type(): + data = request.get_json() or {} + if not data.get('locationtype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'locationtype is required') + existing = LocationType.query.filter_by(locationtype=data['locationtype']).first() + if existing: + if not existing.isactive: + existing.isactive = True + for key in ('description', 'color'): + if data.get(key) is not None: + setattr(existing, key, data[key]) + db.session.commit() + return success_response(_loc_type_dict(existing), message='Reactivated existing type') + return error_response(ErrorCodes.CONFLICT, + f"Location type '{data['locationtype']}' already exists", http_code=409) + t = LocationType(locationtype=data['locationtype'], + description=data.get('description'), color=data.get('color')) + db.session.add(t) + db.session.commit() + return success_response(_loc_type_dict(t), message='Location type created', http_code=201) + + +@locations_bp.route('/types/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_location_type(type_id): + t = db.session.get(LocationType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Location type not found', http_code=404) + data = request.get_json() or {} + if 'locationtype' in data and data['locationtype'] != t.locationtype: + if LocationType.query.filter_by(locationtype=data['locationtype']).first(): + return error_response(ErrorCodes.CONFLICT, + f"Location type '{data['locationtype']}' already exists", http_code=409) + for key in ('locationtype', 'description', 'color', 'isactive'): + if key in data: + setattr(t, key, data[key]) + db.session.commit() + return success_response(_loc_type_dict(t), message='Location type updated') + + +@locations_bp.route('/types/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_location_type(type_id): + t = db.session.get(LocationType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Location type not found', http_code=404) + inuse = Location.query.filter_by(locationtypeid=type_id).count() + if inuse: + return error_response(ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} location(s) still use this type", http_code=409) + db.session.delete(t) + db.session.commit() + return success_response(message='Location type deleted') + + +@locations_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_locations(): + """List all locations.""" + page, per_page = get_pagination_params(request) + + query = Location.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Location.isactive == True) + + # Exact-match natural-key lookup for idempotent import (location name). + if exactname := request.args.get('locationname'): + query = query.filter(Location.locationname == exactname) + + if search := request.args.get('search'): + query = query.filter( + db.or_( + Location.locationname.ilike(f'%{search}%'), + Location.building.ilike(f'%{search}%') + ) + ) + + query = query.order_by(Location.locationname) + + items, total = paginate_query(query, page, per_page) + data = [loc.to_dict() for loc in items] + + return paginated_response(data, page, per_page, total) + + +@locations_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_location(location_id: int): + """Get a single location.""" + loc = db.session.get(Location, location_id) + + if not loc: + return error_response( + ErrorCodes.NOT_FOUND, + f'Location with ID {location_id} not found', + http_code=404 + ) + + return success_response(loc.to_dict()) + + +@locations_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_location(): + """Create a new location.""" + data = request.get_json() + + if not data or not data.get('locationname'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'locationname is required') + + if Location.query.filter_by(locationname=data['locationname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Location '{data['locationname']}' already exists", + http_code=409 + ) + + loc = Location( + locationname=data['locationname'], + building=data.get('building'), + floor=data.get('floor'), + room=data.get('room'), + description=data.get('description'), + locationtypeid=data.get('locationtypeid'), + parentlocationid=data.get('parentlocationid'), + mapimage=data.get('mapimage'), + mapwidth=data.get('mapwidth'), + mapheight=data.get('mapheight') + ) + + db.session.add(loc) + apply_import_timestamps(loc, data) + db.session.commit() + + return success_response(loc.to_dict(), message='Location created', http_code=201) + + +@locations_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_location(location_id: int): + """Update a location.""" + loc = db.session.get(Location, location_id) + + if not loc: + return error_response( + ErrorCodes.NOT_FOUND, + f'Location with ID {location_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'locationname' in data and data['locationname'] != loc.locationname: + if Location.query.filter_by(locationname=data['locationname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Location '{data['locationname']}' already exists", + http_code=409 + ) + + for key in ['locationname', 'building', 'floor', 'room', 'description', + 'locationtypeid', 'parentlocationid', 'mapimage', 'mapwidth', + 'mapheight', 'isactive']: + if key in data: + setattr(loc, key, data[key]) + + apply_import_timestamps(loc, data) + db.session.commit() + return success_response(loc.to_dict(), message='Location updated') + + +@locations_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_location(location_id: int): + """Delete (deactivate) a location.""" + loc = db.session.get(Location, location_id) + + if not loc: + return error_response( + ErrorCodes.NOT_FOUND, + f'Location with ID {location_id} not found', + http_code=404 + ) + + loc.isactive = False + db.session.commit() + + return success_response(message='Location deleted') diff --git a/shopdb/core/api/models.py b/shopdb/core/api/models.py new file mode 100644 index 00000000..e3688bd4 --- /dev/null +++ b/shopdb/core/api/models.py @@ -0,0 +1,262 @@ +"""Models (vendor model catalog) API endpoints - Full CRUD.""" + +import glob +import os + +from flask import Blueprint, request, current_app, send_from_directory +from flask_jwt_extended import jwt_required +from werkzeug.utils import secure_filename + +from shopdb.extensions import db +from shopdb.core.models import Model +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from shopdb.utils.authz import require_role +from shopdb.utils.import_mode import apply_import_timestamps + +models_bp = Blueprint('models', __name__) + +# Uploaded model photos live in the instance dir and are served publicly +# (asset detail pages read the model image without auth). Same image set the +# map/branding uploads accept. +MODEL_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} + +# URL prefix an uploaded image resolves to. imageurl values with this prefix +# are our own files under the instance dir; anything else (external URLs or the +# shipped /images/models/* assets) is left on disk untouched. +MODEL_IMAGE_URL_PREFIX = '/api/models/image/' + + +def _modelimage_dir(): + return os.path.join(current_app.instance_path, 'modelimages') + + +@models_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_models(): + """List all vendor catalog models.""" + page, per_page = get_pagination_params(request) + + query = Model.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Model.isactive == True) + + if vendor_id := request.args.get('vendor', type=int): + query = query.filter(Model.vendorid == vendor_id) + + if modeltype_id := request.args.get('modeltype', type=int): + query = query.filter(Model.modeltypeid == modeltype_id) + + # Exact-match natural-key lookup for idempotent import. Natural key is + # modelnumber + vendor; pair this with ?vendor= to disambiguate. + if exactmodelnumber := request.args.get('modelnumber'): + query = query.filter(Model.modelnumber == exactmodelnumber) + + if search := request.args.get('search'): + query = query.filter(Model.modelnumber.ilike(f'%{search}%')) + + query = query.order_by(Model.modelnumber) + + items, total = paginate_query(query, page, per_page) + + data = [] + for m in items: + d = m.to_dict() + d['vendor'] = m.vendor.vendor if m.vendor else None + d['modeltype'] = m.modeltype.modeltype if m.modeltype else None + data.append(d) + + return paginated_response(data, page, per_page, total) + + +@models_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_model(model_id: int): + """Get a single model.""" + m = db.session.get(Model, model_id) + + if not m: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model with ID {model_id} not found', + http_code=404 + ) + + data = m.to_dict() + data['vendor'] = m.vendor.to_dict() if m.vendor else None + data['modeltype'] = m.modeltype.to_dict() if m.modeltype else None + + return success_response(data) + + +@models_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_model(): + """Create a new model.""" + data = request.get_json() + + if not data or not data.get('modelnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'modelnumber is required') + + # Check duplicate + existing = Model.query.filter_by( + modelnumber=data['modelnumber'], + vendorid=data.get('vendorid') + ).first() + if existing: + return error_response( + ErrorCodes.CONFLICT, + f"Model '{data['modelnumber']}' already exists for this vendor", + http_code=409 + ) + + m = Model( + modelnumber=data['modelnumber'], + vendorid=data.get('vendorid'), + modeltypeid=data.get('modeltypeid'), + description=data.get('description'), + imageurl=data.get('imageurl'), + documentationurl=data.get('documentationurl'), + notes=data.get('notes') + ) + + db.session.add(m) + apply_import_timestamps(m, data) + db.session.commit() + + return success_response(m.to_dict(), message='Model created', http_code=201) + + +@models_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_model(model_id: int): + """Update a model.""" + m = db.session.get(Model, model_id) + + if not m: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model with ID {model_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + for key in ['modelnumber', 'vendorid', 'modeltypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']: + if key in data: + setattr(m, key, data[key]) + + apply_import_timestamps(m, data) + db.session.commit() + return success_response(m.to_dict(), message='Model updated') + + +@models_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_model(model_id: int): + """Delete (deactivate) a model.""" + m = db.session.get(Model, model_id) + + if not m: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model with ID {model_id} not found', + http_code=404 + ) + + m.isactive = False + db.session.commit() + + return success_response(message='Model deleted') + + +@models_bp.route('//image', methods=['POST']) +@jwt_required() +@require_role('admin') +def upload_model_image(model_id: int): + """Upload (or replace) the photo for a model. + + multipart/form-data: file=. Saves to the instance modelimages dir as + model- (one image per model) and points model.imageurl at the + served URL. Re-upload replaces the old file even when the extension changes. + """ + m = db.session.get(Model, model_id) + if not m: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model with ID {model_id} not found', + http_code=404 + ) + + upload = request.files.get('file') + if not upload or not upload.filename: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') + ext = os.path.splitext(upload.filename)[1].lower() + if ext not in MODEL_IMAGE_EXTENSIONS: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Unsupported image type {ext}') + + imagedir = _modelimage_dir() + os.makedirs(imagedir, exist_ok=True) + + # Wipe any prior model-.* file so a new extension does not orphan the old + # one (one image per model). + for old in glob.glob(os.path.join(imagedir, secure_filename(f'model-{model_id}') + '.*')): + os.remove(old) + + filename = secure_filename(f'model-{model_id}{ext}') + upload.save(os.path.join(imagedir, filename)) + + m.imageurl = f'{MODEL_IMAGE_URL_PREFIX}{filename}' + db.session.commit() + + return success_response(m.to_dict(), message='Model image uploaded') + + +@models_bp.route('/image/', methods=['GET']) +def serve_model_image(filename): + """Serve an uploaded model image (public - asset detail pages read it).""" + return send_from_directory(_modelimage_dir(), filename) + + +@models_bp.route('//image', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_model_image(model_id: int): + """Clear a model image and delete the uploaded file if we own it. + + External URLs and the shipped /images/models/* assets are never touched on + disk - only the imageurl field is cleared. + """ + m = db.session.get(Model, model_id) + if not m: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model with ID {model_id} not found', + http_code=404 + ) + + url = m.imageurl or '' + if url.startswith(MODEL_IMAGE_URL_PREFIX): + # secure_filename strips any traversal; the file lives in our dir only. + filename = secure_filename(url[len(MODEL_IMAGE_URL_PREFIX):]) + path = os.path.join(_modelimage_dir(), filename) + if os.path.exists(path): + os.remove(path) + + m.imageurl = None + db.session.commit() + + return success_response(m.to_dict(), message='Model image removed') diff --git a/shopdb/core/api/modeltypes.py b/shopdb/core/api/modeltypes.py new file mode 100644 index 00000000..71def4c3 --- /dev/null +++ b/shopdb/core/api/modeltypes.py @@ -0,0 +1,164 @@ +"""Model Types API endpoints - Full CRUD. + +Types the vendor MODELS catalog (models.modeltypeid). Renamed from +machinetypes; the machines plugin now owns the "machinetypes" name. +""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import ModelType +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from shopdb.utils.authz import require_role +from shopdb.utils.import_mode import apply_import_timestamps + +modeltypes_bp = Blueprint('modeltypes', __name__) + + +@modeltypes_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_modeltypes(): + """List all model types with optional filtering.""" + page, per_page = get_pagination_params(request) + + query = ModelType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(ModelType.isactive == True) + + if category := request.args.get('category'): + query = query.filter(ModelType.category == category) + + # Exact-match natural-key lookup for idempotent import (type name). + if exactmodeltype := request.args.get('modeltype'): + query = query.filter(ModelType.modeltype == exactmodeltype) + + if search := request.args.get('search'): + query = query.filter(ModelType.modeltype.ilike(f'%{search}%')) + + query = query.order_by(ModelType.modeltype) + + items, total = paginate_query(query, page, per_page) + data = [mt.to_dict() for mt in items] + + return paginated_response(data, page, per_page, total) + + +@modeltypes_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_modeltype(type_id: int): + """Get a single model type.""" + mt = db.session.get(ModelType, type_id) + + if not mt: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model type with ID {type_id} not found', + http_code=404 + ) + + return success_response(mt.to_dict()) + + +@modeltypes_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_modeltype(): + """Create a new model type.""" + data = request.get_json() + + if not data or not data.get('modeltype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'modeltype is required') + + if ModelType.query.filter_by(modeltype=data['modeltype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Model type '{data['modeltype']}' already exists", + http_code=409 + ) + + mt = ModelType( + modeltype=data['modeltype'], + category=data.get('category', 'Equipment'), + description=data.get('description'), + icon=data.get('icon') + ) + + db.session.add(mt) + apply_import_timestamps(mt, data) + db.session.commit() + + return success_response(mt.to_dict(), message='Model type created', http_code=201) + + +@modeltypes_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_modeltype(type_id: int): + """Update a model type.""" + mt = db.session.get(ModelType, type_id) + + if not mt: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model type with ID {type_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Check duplicate name + if 'modeltype' in data and data['modeltype'] != mt.modeltype: + if ModelType.query.filter_by(modeltype=data['modeltype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Model type '{data['modeltype']}' already exists", + http_code=409 + ) + + for key in ['modeltype', 'category', 'description', 'icon', 'isactive']: + if key in data: + setattr(mt, key, data[key]) + + apply_import_timestamps(mt, data) + db.session.commit() + return success_response(mt.to_dict(), message='Model type updated') + + +@modeltypes_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_modeltype(type_id: int): + """Delete (deactivate) a model type.""" + mt = db.session.get(ModelType, type_id) + + if not mt: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model type with ID {type_id} not found', + http_code=404 + ) + + # Check if any model uses this type + from shopdb.core.models import Model + if Model.query.filter_by(modeltypeid=type_id).first(): + return error_response( + ErrorCodes.CONFLICT, + 'Cannot delete model type: models are using it', + http_code=409 + ) + + mt.isactive = False + db.session.commit() + + return success_response(message='Model type deleted') diff --git a/shopdb/core/api/operatingsystems.py b/shopdb/core/api/operatingsystems.py new file mode 100644 index 00000000..f6fa4d49 --- /dev/null +++ b/shopdb/core/api/operatingsystems.py @@ -0,0 +1,146 @@ +"""Operating Systems API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import OperatingSystem +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from shopdb.utils.authz import require_role +from shopdb.utils.import_mode import apply_import_timestamps + +operatingsystems_bp = Blueprint('operatingsystems', __name__) + + +@operatingsystems_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_operatingsystems(): + """List all operating systems.""" + page, per_page = get_pagination_params(request) + + query = OperatingSystem.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(OperatingSystem.isactive == True) + + # Exact-match natural-key lookup for idempotent import. Legacy OS rows have + # only a name; pair with ?osversion= when versions are tracked separately. + if exactosname := request.args.get('osname'): + query = query.filter(OperatingSystem.osname == exactosname) + if exactosversion := request.args.get('osversion'): + query = query.filter(OperatingSystem.osversion == exactosversion) + + if search := request.args.get('search'): + query = query.filter(OperatingSystem.osname.ilike(f'%{search}%')) + + query = query.order_by(OperatingSystem.osname) + + items, total = paginate_query(query, page, per_page) + data = [os.to_dict() for os in items] + + return paginated_response(data, page, per_page, total) + + +@operatingsystems_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_operatingsystem(os_id: int): + """Get a single operating system.""" + os = db.session.get(OperatingSystem, os_id) + + if not os: + return error_response( + ErrorCodes.NOT_FOUND, + f'Operating system with ID {os_id} not found', + http_code=404 + ) + + return success_response(os.to_dict()) + + +@operatingsystems_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_operatingsystem(): + """Create a new operating system.""" + data = request.get_json() + + if not data or not data.get('osname'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'osname is required') + + existing = OperatingSystem.query.filter_by( + osname=data['osname'], + osversion=data.get('osversion') + ).first() + if existing: + return error_response( + ErrorCodes.CONFLICT, + f"Operating system '{data['osname']} {data.get('osversion', '')}' already exists", + http_code=409 + ) + + os = OperatingSystem( + osname=data['osname'], + osversion=data.get('osversion'), + architecture=data.get('architecture'), + endoflife=data.get('endoflife') + ) + + db.session.add(os) + apply_import_timestamps(os, data) + db.session.commit() + + return success_response(os.to_dict(), message='Operating system created', http_code=201) + + +@operatingsystems_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_operatingsystem(os_id: int): + """Update an operating system.""" + os = db.session.get(OperatingSystem, os_id) + + if not os: + return error_response( + ErrorCodes.NOT_FOUND, + f'Operating system with ID {os_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + for key in ['osname', 'osversion', 'architecture', 'endoflife', 'isactive']: + if key in data: + setattr(os, key, data[key]) + + apply_import_timestamps(os, data) + db.session.commit() + return success_response(os.to_dict(), message='Operating system updated') + + +@operatingsystems_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_operatingsystem(os_id: int): + """Delete (deactivate) an operating system.""" + os = db.session.get(OperatingSystem, os_id) + + if not os: + return error_response( + ErrorCodes.NOT_FOUND, + f'Operating system with ID {os_id} not found', + http_code=404 + ) + + os.isactive = False + db.session.commit() + + return success_response(message='Operating system deleted') diff --git a/shopdb/core/api/plugins.py b/shopdb/core/api/plugins.py new file mode 100644 index 00000000..cbd74b2d --- /dev/null +++ b/shopdb/core/api/plugins.py @@ -0,0 +1,74 @@ +"""Plugin introspection + enable/disable API.""" + +from flask import Blueprint, current_app, request +from flask_jwt_extended import jwt_required + +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +from shopdb.utils.authz import require_role + +plugins_bp = Blueprint('plugins', __name__) + + +@plugins_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_plugins(): + """List all discovered plugins (enabled or not) with their metadata and + the framework contract version.""" + from shopdb import __contract_version__ + pm = current_app.extensions.get('plugin_manager') + plugins = pm.discover_available() if pm else [] + plugins.sort(key=lambda p: p['name']) + return success_response({ + 'contract_version': __contract_version__, + 'count': len(plugins), + 'plugins': plugins, + }) + + +@plugins_bp.route('/enabled', methods=['GET']) +@jwt_required(optional=True) +def list_enabled_plugins(): + """Return just the names of enabled plugins as a flat array. + + Cheap registry read (no DB). Exposed to anonymous callers on purpose: + the navigation endpoint already leaks the same enabled/disabled signal, + and the frontend needs it (including unauthenticated kiosk routes like + /tv) to gate plugin-owned routes. No metadata beyond the names. + """ + pm = current_app.extensions.get('plugin_manager') + names = pm.registry.get_enabled_plugins() if pm else [] + return success_response(sorted(names)) + + +@plugins_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def set_plugin_enabled(name: str): + """Enable or disable a plugin. Takes effect on the next app restart for + route/navigation changes.""" + data = request.get_json() or {} + if 'enabled' not in data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'enabled is required') + + pm = current_app.extensions.get('plugin_manager') + if not pm: + return error_response(ErrorCodes.INTERNAL_ERROR, + 'Plugin manager unavailable', http_code=500) + + want = bool(data['enabled']) + ok = pm.enable_plugin(name) if want else pm.disable_plugin(name) + if not ok: + # enable/disable refused (unknown plugin, or a dependency conflict) + return error_response( + ErrorCodes.CONFLICT, + f"Could not {'enable' if want else 'disable'} '{name}' " + f"(check it exists and dependencies allow it)", + http_code=409 + ) + + return success_response( + {'name': name, 'enabled': want}, + message=f"Plugin {'enabled' if want else 'disabled'} " + f"(restart to apply route changes)" + ) diff --git a/shopdb/core/api/pluginui.py b/shopdb/core/api/pluginui.py new file mode 100644 index 00000000..90a41e8b --- /dev/null +++ b/shopdb/core/api/pluginui.py @@ -0,0 +1,108 @@ +"""Plugin frontend-contribution API endpoints (ADR-010). + +Consumers for the four data-only presentation hooks on BasePlugin: +get_settings_cards, get_asset_panels, get_map_overlays, get_asset_presentation. + +Every endpoint copies the dashboard-widgets consumer semantics: skip disabled +plugins, tag each contributed dict with its originating plugin name, fail loud +in dev/test, isolate a broken plugin in prod. All routes are GET + jwt-optional, +matching the other read-only core endpoints; they expose data, not mutations. +""" + +from flask import Blueprint, request, current_app +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Asset +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +pluginui_bp = Blueprint('pluginui', __name__) + + +def _collect(hookname): + """Aggregate a data-only hook across enabled plugins. + + Same access pattern as dashboard.get_widgets: skip disabled plugins, inject + the plugin name on every returned dict, re-raise in dev/test, log-and-isolate + a broken plugin in prod. Returns the merged list (unsorted). + """ + pm = current_app.extensions.get('plugin_manager') + if not pm: + return [] + + merged = [] + for name, plugin in pm.get_all_plugins().items(): + if not pm.registry.is_enabled(name): + continue + try: + for entry in getattr(plugin, hookname)() or []: + entry['plugin'] = name + merged.append(entry) + except Exception: + # fail loud in dev/test, isolate broke plugin in prod + if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): + raise + current_app.logger.exception('Plugin %s %s failed', name, hookname) + return merged + + +@pluginui_bp.route('/settings-cards', methods=['GET']) +@jwt_required(optional=True) +def settings_cards(): + """Merge enabled plugins' settings-catalog cards (get_settings_cards hook). + + The frontend merges these into the core settingsNav catalog and renders + them in the settings rail + landing overview. + """ + cards = _collect('get_settings_cards') + cards.sort(key=lambda card: card.get('position', 99)) + return success_response(cards) + + +@pluginui_bp.route('/asset-panels', methods=['GET']) +@jwt_required(optional=True) +def asset_panels(): + """Return the asset-detail panels matching one asset's type (get_asset_panels). + + Query param assetid is required. A panel matches when its assettypes list + contains the asset's type key or the wildcard '*'. + """ + assetid = request.args.get('assetid', type=int) + if not assetid: + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetid is required') + + asset = db.session.get(Asset, assetid) + if not asset: + return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404) + assettype = asset.assettype.assettype if asset.assettype else None + + matched = [] + for panel in _collect('get_asset_panels'): + assettypes = panel.get('assettypes') or [] + if '*' in assettypes or (assettype and assettype in assettypes): + matched.append(panel) + matched.sort(key=lambda panel: panel.get('position', 99)) + return success_response(matched) + + +@pluginui_bp.route('/map-overlays', methods=['GET']) +@jwt_required(optional=True) +def map_overlays(): + """Merge enabled plugins' map overlay declarations (get_map_overlays hook). + + The map fetches each overlay endpoint and decorates already-placed markers. + """ + overlays = _collect('get_map_overlays') + overlays.sort(key=lambda overlay: overlay.get('position', 99)) + return success_response(overlays) + + +@pluginui_bp.route('/asset-presentation', methods=['GET']) +@jwt_required(optional=True) +def asset_presentation(): + """Merge enabled plugins' asset-type presentation entries (hook). + + Search rows and cross-links use these to pick a type's icon and detail route. + """ + entries = _collect('get_asset_presentation') + return success_response(entries) diff --git a/shopdb/core/api/reports.py b/shopdb/core/api/reports.py new file mode 100644 index 00000000..c31d93f0 --- /dev/null +++ b/shopdb/core/api/reports.py @@ -0,0 +1,636 @@ +"""Reports API endpoints.""" + +import csv +import io +from datetime import datetime, timezone +from flask import Blueprint, request, Response, current_app +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import ( + Asset, AssetType, AssetStatus, + Application +) +from shopdb.utils.responses import success_response, error_response, ErrorCodes +from shopdb.utils.authz import require_permission + +reports_bp = Blueprint('reports', __name__) + + +def generate_csv(data: list, columns: list) -> str: + """Generate CSV string from data.""" + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(columns) + for row in data: + writer.writerow([row.get(col, '') for col in columns]) + return output.getvalue() + + +# ============================================================================= +# Report: Machines by Type +# ============================================================================= + +@reports_bp.route('/machines-by-type', methods=['GET']) +@jwt_required(optional=True) +def machines_by_type(): + """ + Report: Machine count grouped by machine type. + + Query parameters: + - businessunitid: Filter by business unit + - format: 'json' (default) or 'csv' + """ + try: + from plugins.machines.models import Machine, MachineType + except ImportError: + return error_response( + ErrorCodes.NOT_FOUND, + 'Machines plugin not installed', + http_code=404 + ) + + query = db.session.query( + MachineType.machinetype, + MachineType.description, + db.func.count(Machine.machineid).label('count') + ).outerjoin(Machine, Machine.machinetypeid == MachineType.machinetypeid + ).outerjoin(Asset, Asset.assetid == Machine.assetid + ).filter( + db.or_(Asset.isactive == True, Asset.assetid.is_(None)), + MachineType.isactive == True + ) + + # Business unit filter + if bu_id := request.args.get('businessunitid'): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Dualpath single-machine collapse: exclude the secondary bay so a dual-bay + # pair counts once per type bucket. Gated on the site setting (default on). + from shopdb.core.services.dualpath import ( + resolve_dualpath_pairs, dualpath_single_machine_enabled) + if dualpath_single_machine_enabled(): + secondaryassetids = resolve_dualpath_pairs().secondaryassetids + if secondaryassetids: + query = query.filter( + db.or_(Machine.assetid.is_(None), + Machine.assetid.notin_(secondaryassetids))) + + query = query.group_by( + MachineType.machinetypeid, + MachineType.machinetype, + MachineType.description + ).order_by(MachineType.machinetype) + + results = query.all() + data = [{'machinetype': r.machinetype, 'description': r.description or '', 'count': r.count} for r in results] + total = sum(r['count'] for r in data) + + if request.args.get('format') == 'csv': + csv_data = generate_csv(data, ['machinetype', 'description', 'count']) + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=machines_by_type.csv'} + ) + + return success_response({ + 'report': 'machines_by_type', + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'data': data, + 'total': total + }) + + +# ============================================================================= +# Report: Assets by Status +# ============================================================================= + +@reports_bp.route('/assets-by-status', methods=['GET']) +@jwt_required(optional=True) +def assets_by_status(): + """ + Report: Asset count grouped by status. + + Query parameters: + - assettypeid: Filter by asset type + - businessunitid: Filter by business unit + - format: 'json' (default) or 'csv' + """ + query = db.session.query( + AssetStatus.status, + AssetStatus.color, + db.func.count(Asset.assetid).label('count') + ).outerjoin(Asset, Asset.statusid == AssetStatus.statusid + ).filter( + db.or_(Asset.isactive == True, Asset.assetid.is_(None)), + AssetStatus.isactive == True + ) + + # Asset type filter + if type_id := request.args.get('assettypeid'): + query = query.filter(Asset.assettypeid == int(type_id)) + + # Business unit filter + if bu_id := request.args.get('businessunitid'): + query = query.filter(Asset.businessunitid == int(bu_id)) + + query = query.group_by( + AssetStatus.statusid, + AssetStatus.status, + AssetStatus.color + ).order_by(AssetStatus.status) + + results = query.all() + data = [{'status': r.status, 'color': r.color or '', 'count': r.count} for r in results] + total = sum(r['count'] for r in data) + + if request.args.get('format') == 'csv': + csv_data = generate_csv(data, ['status', 'count']) + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=assets_by_status.csv'} + ) + + return success_response({ + 'report': 'assets_by_status', + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'data': data, + 'total': total + }) + + +# ============================================================================= +# Report: KB Popularity +# ============================================================================= + +@reports_bp.route('/kb-popularity', methods=['GET']) +@jwt_required(optional=True) +def kb_popularity(): + """ + Report: Most clicked knowledge base articles. + + Query parameters: + - limit: Number of results (default 20) + - format: 'json' (default) or 'csv' + """ + limit = min(int(request.args.get('limit', 20)), 100) + + # Knowledge Base is a plugin; degrade gracefully if it is not installed. + try: + from plugins.knowledgebase.models import KnowledgeBase + except ImportError: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'KB popularity requires the knowledgebase plugin', + http_code=503) + + articles = KnowledgeBase.query.filter( + KnowledgeBase.isactive == True + ).order_by( + KnowledgeBase.clicks.desc() + ).limit(limit).all() + + data = [] + for kb in articles: + data.append({ + 'linkid': kb.linkid, + 'shortdescription': kb.shortdescription, + 'application': kb.application.appname if kb.application else None, + 'clicks': kb.clicks or 0, + 'linkurl': kb.linkurl + }) + + if request.args.get('format') == 'csv': + csv_data = generate_csv(data, ['linkid', 'shortdescription', 'application', 'clicks', 'linkurl']) + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=kb_popularity.csv'} + ) + + return success_response({ + 'report': 'kb_popularity', + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'data': data, + 'total': len(data) + }) + + +# ============================================================================= +# Report: Software Compliance +# ============================================================================= + +@reports_bp.route('/software-compliance', methods=['GET']) +@jwt_required(optional=True) +def software_compliance(): + """ + Report: Required applications vs installed (per PC). + + Shows which PCs have required applications installed. + + Query parameters: + - appid: Filter to specific application + - format: 'json' (default) or 'csv' + """ + # Install tracking lives in the computers plugin; degrade gracefully if + # it is not installed. + try: + from plugins.computers.models import Computer, ComputerInstalledApp + except ImportError: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Software compliance requires the computers plugin', + http_code=503) + + # Get required applications + required_apps = Application.query.filter( + Application.isactive == True, + Application.isrequired == True + ).all() + + if not required_apps: + return success_response({ + 'report': 'software_compliance', + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'data': [], + 'message': 'No required applications defined' + }) + + app_filter = request.args.get('appid') + + data = [] + for app in required_apps: + if app_filter and str(app.appid) != app_filter: + continue + + # Get all PCs (computers) + total_pcs = Computer.query.join( + Asset, Asset.assetid == Computer.assetid + ).filter(Asset.isactive == True).count() + + # Get PCs with this app installed + installed_count = db.session.query(ComputerInstalledApp).join( + Computer, Computer.computerid == ComputerInstalledApp.computerid + ).join( + Asset, Asset.assetid == Computer.assetid + ).filter( + ComputerInstalledApp.appid == app.appid, + ComputerInstalledApp.isactive == True, + Asset.isactive == True + ).count() + + # Get list of non-compliant PCs + compliant_pc_ids = db.session.query(ComputerInstalledApp.computerid).filter( + ComputerInstalledApp.appid == app.appid, + ComputerInstalledApp.isactive == True + ).subquery() + + non_compliant_pcs = Computer.query.join( + Asset, Asset.assetid == Computer.assetid + ).filter( + Asset.isactive == True, + ~Computer.computerid.in_(compliant_pc_ids) + ).limit(100).all() + + compliance_rate = (installed_count / total_pcs * 100) if total_pcs > 0 else 0 + + data.append({ + 'appid': app.appid, + 'appname': app.appname, + 'totalpcs': total_pcs, + 'installed': installed_count, + 'notinstalled': total_pcs - installed_count, + 'compliancerate': round(compliance_rate, 1), + 'noncompliantpcs': [ + {'computerid': pc.computerid, + 'hostname': pc.hostname or (pc.asset.assetnumber if pc.asset else None)} + for pc in non_compliant_pcs + ] + }) + + if request.args.get('format') == 'csv': + # Simplified CSV (no nested data) + csv_rows = [] + for item in data: + csv_rows.append({ + 'appid': item['appid'], + 'appname': item['appname'], + 'totalpcs': item['totalpcs'], + 'installed': item['installed'], + 'notinstalled': item['notinstalled'], + 'compliancerate': item['compliancerate'] + }) + csv_data = generate_csv(csv_rows, ['appid', 'appname', 'totalpcs', 'installed', 'notinstalled', 'compliancerate']) + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=software_compliance.csv'} + ) + + return success_response({ + 'report': 'software_compliance', + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'data': data, + 'total': len(data) + }) + + +# ============================================================================= +# Report: Asset Inventory Summary +# ============================================================================= + +@reports_bp.route('/asset-inventory', methods=['GET']) +@jwt_required(optional=True) +def asset_inventory(): + """ + Report: Complete asset inventory summary. + + Query parameters: + - businessunitid: Filter by business unit + - locationid: Filter by location + - format: 'json' (default) or 'csv' + """ + # By asset type + by_type = db.session.query( + AssetType.assettype, + db.func.count(Asset.assetid).label('count') + ).outerjoin(Asset, Asset.assettypeid == AssetType.assettypeid + ).filter( + db.or_(Asset.isactive == True, Asset.assetid.is_(None)), + AssetType.isactive == True + ) + + if bu_id := request.args.get('businessunitid'): + by_type = by_type.filter(Asset.businessunitid == int(bu_id)) + if loc_id := request.args.get('locationid'): + by_type = by_type.filter(Asset.locationid == int(loc_id)) + + by_type = by_type.group_by(AssetType.assettype).all() + + # By status + by_status = db.session.query( + AssetStatus.status, + AssetStatus.color, + db.func.count(Asset.assetid).label('count') + ).outerjoin(Asset, Asset.statusid == AssetStatus.statusid + ).filter( + db.or_(Asset.isactive == True, Asset.assetid.is_(None)), + AssetStatus.isactive == True + ) + + if bu_id := request.args.get('businessunitid'): + by_status = by_status.filter(Asset.businessunitid == int(bu_id)) + if loc_id := request.args.get('locationid'): + by_status = by_status.filter(Asset.locationid == int(loc_id)) + + by_status = by_status.group_by(AssetStatus.status, AssetStatus.color).all() + + # By location + from shopdb.core.models import Location + by_location = db.session.query( + Location.locationname, + db.func.count(Asset.assetid).label('count') + ).outerjoin(Asset, Asset.locationid == Location.locationid + ).filter( + db.or_(Asset.isactive == True, Asset.assetid.is_(None)), + Location.isactive == True + ) + + if bu_id := request.args.get('businessunitid'): + by_location = by_location.filter(Asset.businessunitid == int(bu_id)) + + by_location = by_location.group_by(Location.locationname).all() + + data = { + 'bytype': [{'type': r.assettype, 'count': r.count} for r in by_type], + 'bystatus': [{'status': r.status, 'color': r.color, 'count': r.count} for r in by_status], + 'bylocation': [{'location': r.locationname, 'count': r.count} for r in by_location] + } + + total = sum(r['count'] for r in data['bytype']) + + if request.args.get('format') == 'csv': + # Create multiple sections in CSV + csv_output = io.StringIO() + + csv_output.write("Asset Inventory Report\n") + csv_output.write(f"Generated: {datetime.now(timezone.utc).replace(tzinfo=None).isoformat()}\n\n") + + csv_output.write("By Type\n") + csv_output.write("Type,Count\n") + for r in data['bytype']: + csv_output.write(f"{r['type']},{r['count']}\n") + + csv_output.write("\nBy Status\n") + csv_output.write("Status,Count\n") + for r in data['bystatus']: + csv_output.write(f"{r['status']},{r['count']}\n") + + csv_output.write("\nBy Location\n") + csv_output.write("Location,Count\n") + for r in data['bylocation']: + csv_output.write(f"{r['location']},{r['count']}\n") + + return Response( + csv_output.getvalue(), + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=asset_inventory.csv'} + ) + + return success_response({ + 'report': 'asset_inventory', + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'data': data, + 'total': total + }) + + +# ============================================================================= +# Available Reports List +# ============================================================================= + +# ============================================================================= +# Report: PC-Machine Relationships +# ============================================================================= + +@reports_bp.route('/pc-relationships', methods=['GET']) +@jwt_required(optional=True) +def pc_relationships(): + """ + Report: PCs with relationships to shop floor machines. + + Returns machine number, vendor, model, PC hostname, and PC IP address + for all active PC-machine relationships. + + Query parameters: + - format: 'json' (default) or 'csv' + """ + # Asset relationships where a computer (source) relates to a machine + # (target) - the asset-model equivalent of the legacy PC->machine links. + sql = db.text(""" + SELECT + eq.assetnumber AS machine_number, + v.vendor AS vendor, + mo.modelnumber AS model, + COALESCE(cpc.hostname, pc.assetnumber) AS hostname, + c.ipaddress AS ip + FROM assetrelationships ar + JOIN assets pc ON ar.sourceassetid = pc.assetid + JOIN computers cpc ON cpc.assetid = pc.assetid + JOIN assets eq ON ar.targetassetid = eq.assetid + JOIN machines eqx ON eqx.assetid = eq.assetid + LEFT JOIN communications c ON c.assetid = pc.assetid AND c.isprimary = 1 AND c.comtypeid = 1 + LEFT JOIN models mo ON eqx.modelnumberid = mo.modelnumberid + LEFT JOIN vendors v ON mo.vendorid = v.vendorid + WHERE ar.isactive = 1 + AND eq.assetnumber IS NOT NULL AND eq.assetnumber != '' + ORDER BY eq.assetnumber + """) + + results = db.session.execute(sql).fetchall() + data = [{ + 'machine_number': r.machine_number, + 'vendor': r.vendor or '', + 'model': r.model or '', + 'hostname': r.hostname or '', + 'ip': r.ip or '' + } for r in results] + + if request.args.get('format') == 'csv': + csv_data = generate_csv(data, ['machine_number', 'vendor', 'model', 'hostname', 'ip']) + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=pc_relationships.csv'} + ) + + return success_response({ + 'report': 'pc_relationships', + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'data': data, + 'total': len(data) + }) + + +@reports_bp.route('/email', methods=['POST']) +@jwt_required() +@require_permission('reports.export') +def email_report(): + """Email a report's data as an HTML table (on-demand delivery). + + Request: + { + "subject": "Warranty Report", + "columns": [{"key": "vendor", "label": "Vendor"}, ...], + "rows": [{"vendor": "...", ...}, ...], + "intro": "optional lead paragraph", + "to": "addr@example.com" // optional; defaults to alert_recipients + } + + Recipients default to the site's Alert Recipients when `to` is omitted, so + this doubles as the alert delivery path. There is no scheduler in this app: + sending is on-demand. To automate it, point an external cron job at this + endpoint using an API token (PAT) scoped to reports.export. + """ + from shopdb.utils.mailer import get_smtp_config, render_table_email, try_send + + data = request.get_json() or {} + subject = data.get('subject') or 'ShopDB report' + columns = data.get('columns') or [] + rows = data.get('rows') or [] + + config = get_smtp_config() + recipient = data.get('to') or config['alert_recipients'] + + if not config['enabled'] or not config['host']: + return success_response( + {'sent': False, 'reason': 'notconfigured'}, + message='Email is not configured (SMTP disabled or host unset).') + if not recipient: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'No recipient. Provide "to" or set Alert Recipients.') + + html, text = render_table_email( + subject, columns, rows, intro=data.get('intro')) + ok, error = try_send(recipient, subject, html, text=text) + if ok: + return success_response({'sent': True}, message='Report emailed.') + return success_response( + {'sent': False, 'error': error}, + message='Report email failed: ' + (error or 'unknown error')) + + +@reports_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_reports(): + """List all available reports.""" + reports = [ + { + 'id': 'machines-by-type', + 'name': 'Machines by Type', + 'description': 'Machine count grouped by machine type', + 'endpoint': '/api/reports/machines-by-type', + 'category': 'inventory' + }, + { + 'id': 'assets-by-status', + 'name': 'Assets by Status', + 'description': 'Asset count grouped by status', + 'endpoint': '/api/reports/assets-by-status', + 'category': 'inventory' + }, + { + 'id': 'kb-popularity', + 'name': 'KB Popularity', + 'description': 'Most clicked knowledge base articles', + 'endpoint': '/api/reports/kb-popularity', + 'category': 'usage' + }, + { + 'id': 'software-compliance', + 'name': 'Software Compliance', + 'description': 'Required applications vs installed', + 'endpoint': '/api/reports/software-compliance', + 'category': 'compliance' + }, + { + 'id': 'asset-inventory', + 'name': 'Asset Inventory Summary', + 'description': 'Complete asset inventory breakdown', + 'endpoint': '/api/reports/asset-inventory', + 'category': 'inventory' + }, + { + 'id': 'pc-relationships', + 'name': 'PC-Machine Relationships', + 'description': 'PCs with relationships to shop floor machines', + 'endpoint': '/api/reports/pc-relationships', + 'category': 'inventory' + } + ] + + # Merge report cards contributed by enabled plugins (get_reports hook). + # Same access pattern as dashboard.get_navigation: skip disabled plugins, + # fail loud in dev/test, isolate a broken plugin in prod. + pm = current_app.extensions.get('plugin_manager') + if pm: + for name, plugin in pm.get_all_plugins().items(): + if not pm.registry.is_enabled(name): + continue + try: + for entry in plugin.get_reports() or []: + entry['plugin'] = name + reports.append(entry) + except Exception: + if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): + raise + current_app.logger.exception( + 'Plugin %s get_reports failed', name) + + return success_response({ + 'reports': reports, + 'total': len(reports) + }) diff --git a/shopdb/core/api/search.py b/shopdb/core/api/search.py new file mode 100644 index 00000000..638db017 --- /dev/null +++ b/shopdb/core/api/search.py @@ -0,0 +1,912 @@ +"""Global search API endpoint with full search parity.""" + +import re +import ipaddress +import logging + +from datetime import datetime, timezone +from flask import Blueprint, request, current_app +from flask_jwt_extended import jwt_required +from sqlalchemy.orm import joinedload + +from shopdb.extensions import db +from shopdb.core.models import ( + Application, Setting, + Asset, AssetType, Communication, Vendor, Model, + CustomField, CustomFieldValue +) +from shopdb.core.api.settings import get_cached_settings +from shopdb.utils.responses import success_response + +logger = logging.getLogger(__name__) + +search_bp = Blueprint('search', __name__) + + +def _require_enabled(name): + """Raise ImportError when the named plugin is disabled. + + Each plugin-scoped search block already catches ImportError and skips the + domain, so a disabled plugin is treated exactly like an absent one: its rows + drop out of search results. Honors runtime enable/disable. + """ + pm = current_app.extensions.get('plugin_manager') + if pm and not pm.registry.is_enabled(name): + raise ImportError(f'{name} plugin disabled') + +# Shipped GE defaults. Settings override these per-site; identical fallbacks +# live here so this consumer works even if the settings seed has not run. +SERVICENOW_URL_DEFAULT = ( + 'https://geaerospaceqa.service-now.com/now/nav/ui/search/' + '0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/' + 'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/' +) +SERVICENOW_PREFIXES_DEFAULT = 'GEINC,GECHG,GERIT,GESCT' +EMPLOYEEID_PATTERN_DEFAULT = r'^\d{9}$' + + +def _get_search_integrations(): + """Resolve the settings-driven search integration config. + + Reads employeeid_pattern, servicenow_ticket_prefixes, servicenow_enabled + and servicenow_search_url from cached settings, falling back to the shipped + GE defaults for any missing key. An invalid employeeid_pattern regex falls + back to the default rather than raising (search must never 500 on bad + config). ServiceNow is inactive when disabled, when the URL is blank, or + when no ticket prefixes are configured. + """ + settings = get_cached_settings() + + # Employee-ID pattern. Bad regex falls back so search never 500s. + pattern = settings.get('employeeid_pattern') or EMPLOYEEID_PATTERN_DEFAULT + try: + employeeid_re = re.compile(pattern) + except re.error: + employeeid_re = re.compile(EMPLOYEEID_PATTERN_DEFAULT) + + # Ticket prefixes -> case-insensitive alternation built at request time. + prefixes_raw = settings.get('servicenow_ticket_prefixes') + if prefixes_raw is None: + prefixes_raw = SERVICENOW_PREFIXES_DEFAULT + prefixes = [p.strip() for p in str(prefixes_raw).split(',') if p.strip()] + + servicenow_enabled = settings.get('servicenow_enabled') + if servicenow_enabled is None: + servicenow_enabled = True + + servicenow_url = settings.get('servicenow_search_url') + if servicenow_url is None: + servicenow_url = SERVICENOW_URL_DEFAULT + + servicenow_active = bool(servicenow_enabled) and bool(servicenow_url) and bool(prefixes) + + prefix_re = None + if servicenow_active: + alternation = '|'.join(re.escape(p) for p in prefixes) + prefix_re = re.compile(r'^(' + alternation + r')\d+', re.IGNORECASE) + + return { + 'employeeid_re': employeeid_re, + 'prefix_re': prefix_re, + 'servicenow_active': servicenow_active, + 'servicenow_url': servicenow_url, + } + + +def _classify_query(query, integrations): + """Analyze the query string to determine its nature.""" + prefix_re = integrations['prefix_re'] + sn_match = prefix_re.match(query) if prefix_re else None + return { + 'is_ip': bool(re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query)), + 'is_sso': bool(integrations['employeeid_re'].match(query)), + 'is_servicenow': bool(sn_match), + 'servicenow_prefix': sn_match.group(1) if sn_match else None, + 'is_fqdn': bool(re.match(r'^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$', query)), + } + + +def _get_asset_result(asset, query, relevance=None): + """Build a search result dict from an Asset object.""" + asset_type_name = asset.assettype.assettype if asset.assettype else 'asset' + + plugin_id = asset.assetid + if asset_type_name == 'machine' and hasattr(asset, 'machine') and asset.machine: + plugin_id = asset.machine.machineid + elif asset_type_name == 'computer' and hasattr(asset, 'computer') and asset.computer: + plugin_id = asset.computer.computerid + elif asset_type_name == 'network_device' and hasattr(asset, 'network_device') and asset.network_device: + plugin_id = asset.network_device.networkdeviceid + elif asset_type_name == 'printer' and hasattr(asset, 'printer') and asset.printer: + plugin_id = asset.printer.printerid + elif asset_type_name == 'measuring_tool' and hasattr(asset, 'measuringtool') and asset.measuringtool: + plugin_id = asset.measuringtool.measuringtoolid + + url_map = { + 'machine': f"/machines/{plugin_id}", + 'computer': f"/pcs/{plugin_id}", + 'network_device': f"/network/{plugin_id}", + 'printer': f"/printers/{plugin_id}", + 'measuring_tool': f"/measuringtools/{plugin_id}", + } + url = url_map.get(asset_type_name, f"/assets/{asset.assetid}") + + display_name = asset.display_name + subtitle = None + if asset.name and asset.assetnumber != asset.name: + subtitle = asset.assetnumber + + location_name = asset.location.locationname if asset.location else None + + if relevance is None: + relevance = 15 + + return { + 'type': asset_type_name, + 'id': plugin_id, + 'title': display_name, + 'subtitle': subtitle, + 'location': location_name, + 'url': url, + 'relevance': relevance + } + + +def _search_applications(query, search_term): + """Search Applications by name and description.""" + results = [] + try: + apps = Application.query.filter( + Application.isactive == True, + db.or_( + Application.appname.ilike(search_term), + Application.appdescription.ilike(search_term) + ) + ).limit(10).all() + + for app in apps: + relevance = 20 + if query.lower() == app.appname.lower(): + relevance = 100 + elif query.lower() in app.appname.lower(): + relevance = 50 + + results.append({ + 'type': 'application', + 'id': app.appid, + 'title': app.appname, + 'subtitle': app.appdescription[:100] if app.appdescription else None, + 'url': f"/applications/{app.appid}", + 'relevance': relevance + }) + except Exception as e: + logger.error(f"Application search failed: {e}") + return results + + +def _search_knowledgebase(query, search_term): + """Search Knowledge Base by description and keywords.""" + results = [] + try: + _require_enabled('knowledgebase') + from plugins.knowledgebase.models import KnowledgeBase + kb_articles = KnowledgeBase.query.filter( + KnowledgeBase.isactive == True, + db.or_( + KnowledgeBase.shortdescription.ilike(search_term), + KnowledgeBase.keywords.ilike(search_term) + ) + ).limit(20).all() + + for kb in kb_articles: + relevance = 10 + (kb.clicks or 0) * 0.1 + if kb.keywords and query.lower() in kb.keywords.lower(): + relevance += 15 + + results.append({ + 'type': 'knowledgebase', + 'id': kb.linkid, + 'title': kb.shortdescription, + 'subtitle': kb.application.appname if kb.application else None, + 'url': f"/knowledgebase/{kb.linkid}", + 'linkurl': kb.linkurl, + 'relevance': relevance + }) + except ImportError: + pass # knowledgebase plugin absent or disabled + except Exception as e: + logger.error(f"KnowledgeBase search failed: {e}") + return results + + +def _employee_rows(search_term): + """Employee rows matching search_term, from the SELFHOSTED directory table + or the external HR DB per the employee_directory_mode setting. Returns dicts + shaped like the external query (SSO/First_Name/Last_Name/Team/Role).""" + settings = get_cached_settings() + mode = (settings.get('employee_directory_mode') or 'external').lower() + if mode == 'selfhosted': + try: + from plugins.employees.models import DirectoryEmployee + except ImportError: + return [] + rows = DirectoryEmployee.query.filter( + db.or_( + DirectoryEmployee.firstname.ilike(search_term), + DirectoryEmployee.lastname.ilike(search_term), + db.cast(DirectoryEmployee.sso, db.String).ilike(search_term), + ) + ).order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname).limit(10).all() + return [{'SSO': e.sso, 'First_Name': e.firstname or '', + 'Last_Name': e.lastname or '', 'Team': e.team, 'Role': e.role} + for e in rows] + # External HR DB: shared env-backed connection helper; never hardcode creds. + from shopdb.utils.employee_db import employee_connection + emp_conn = employee_connection() + with emp_conn.cursor() as cur: + cur.execute(''' + SELECT SSO, First_Name, Last_Name, Team, Role + FROM employees + WHERE First_Name LIKE %s OR Last_Name LIKE %s OR CAST(SSO AS CHAR) LIKE %s + ORDER BY Last_Name, First_Name LIMIT 10 + ''', (search_term, search_term, search_term)) + rows = cur.fetchall() + emp_conn.close() + return rows + + +def _search_employees(query, search_term): + """Search employees (selfhosted directory or external HR DB, per mode).""" + results = [] + try: + for emp in _employee_rows(search_term): + full_name = f"{emp['First_Name'].strip()} {emp['Last_Name'].strip()}" + sso_str = str(emp['SSO']) + + relevance = 20 + if query == sso_str: + relevance = 100 + elif query.lower() == full_name.lower(): + relevance = 95 + elif query.lower() in full_name.lower(): + relevance = 60 + + results.append({ + 'type': 'employee', + 'id': emp['SSO'], + 'title': full_name, + 'subtitle': emp.get('Team') or emp.get('Role') or f"SSO: {sso_str}", + 'url': f"/employees/{emp['SSO']}", + 'relevance': relevance + }) + except Exception as e: + logger.error(f"Employee search failed: {e}") + return results + + +def _search_assets(query, search_term): + """Search unified Assets table by number, name, serial, notes.""" + results = [] + try: + assets = Asset.query.join(AssetType).options( + joinedload(Asset.assettype), + joinedload(Asset.location), + ).filter( + Asset.isactive == True, + db.or_( + Asset.assetnumber.ilike(search_term), + Asset.name.ilike(search_term), + Asset.serialnumber.ilike(search_term), + Asset.notes.ilike(search_term) + ) + ).limit(15).all() + + for asset in assets: + relevance = 15 + if asset.assetnumber and query.lower() == asset.assetnumber.lower(): + relevance = 100 + elif asset.name and query.lower() == asset.name.lower(): + relevance = 90 + elif asset.serialnumber and query.lower() == asset.serialnumber.lower(): + relevance = 85 + elif asset.name and query.lower() in asset.name.lower(): + relevance = 50 + + results.append(_get_asset_result(asset, query, relevance)) + except Exception as e: + logger.error(f"Asset search failed: {e}") + return results + + +def _search_measuringtools(query, search_term): + """Search measuring tools, including the gaugelabreference identifier. + + The generic asset search already matches number/name/serial across all asset + types; this gated searcher adds gaugelabreference (the gage tag the gage lab + searches by) and drops out when the measuringtools plugin is disabled. + """ + results = [] + try: + _require_enabled('measuringtools') + from plugins.measuringtools.models import MeasuringTool + assets = db.session.query(Asset).join( + MeasuringTool, MeasuringTool.assetid == Asset.assetid + ).options( + joinedload(Asset.assettype), + joinedload(Asset.location), + ).filter( + Asset.isactive == True, + db.or_( + Asset.assetnumber.ilike(search_term), + Asset.name.ilike(search_term), + Asset.serialnumber.ilike(search_term), + Asset.gaugelabreference.ilike(search_term), + ) + ).limit(15).all() + + for asset in assets: + relevance = 15 + if asset.assetnumber and query.lower() == asset.assetnumber.lower(): + relevance = 100 + elif asset.gaugelabreference and query.lower() == asset.gaugelabreference.lower(): + relevance = 90 + elif asset.serialnumber and query.lower() == asset.serialnumber.lower(): + relevance = 85 + elif asset.name and query.lower() in asset.name.lower(): + relevance = 50 + results.append(_get_asset_result(asset, query, relevance)) + except ImportError: + pass # measuringtools plugin absent or disabled + except Exception as e: + logger.error(f"Measuring tool search failed: {e}") + return results + + +def _search_customfields(query, search_term): + """Search custom-field VALUES for fields flagged searchable. + + Joins CustomFieldValue -> CustomField (searchable + active) -> Asset (active) + and emits a normal asset result routed to that asset's detail page. Results + are deduped against built-in-field asset matches by the shared (type, id) key + in global_search, and the asset's search__enabled domain toggle is + applied by the same end-of-request domain filter. The matched field label is + put in the subtitle so a value hit reads sensibly. + """ + results = [] + try: + rows = db.session.query(Asset, CustomField).join( + CustomFieldValue, CustomFieldValue.assetid == Asset.assetid + ).join( + CustomField, CustomField.fieldid == CustomFieldValue.fieldid + ).options( + joinedload(Asset.assettype), + joinedload(Asset.location), + ).filter( + Asset.isactive == True, + CustomField.searchable == True, + CustomField.isactive == True, + CustomFieldValue.value.ilike(search_term), + ).limit(15).all() + + for asset, field in rows: + result = _get_asset_result(asset, query, relevance=40) + result['subtitle'] = field.label + results.append(result) + except Exception as e: + logger.error(f"Custom field search failed: {e}") + return results + + +def _search_by_ip(query, search_term): + """Search Communications table for IP address matches.""" + results = [] + try: + comms = Communication.query.filter( + Communication.ipaddress.ilike(search_term) + ).options( + joinedload(Communication.asset).joinedload(Asset.assettype), + joinedload(Communication.asset).joinedload(Asset.location), + ).limit(10).all() + + seen_assets = set() + for comm in comms: + asset = comm.asset + if not asset or not asset.isactive or asset.assetid in seen_assets: + continue + seen_assets.add(asset.assetid) + + relevance = 80 if query == comm.ipaddress else 40 + result = _get_asset_result(asset, query, relevance) + result['subtitle'] = comm.ipaddress + results.append(result) + except Exception as e: + logger.error(f"IP search failed: {e}") + return results + + +def _search_subnets(query): + """Find which subnet an IP address belongs to.""" + results = [] + try: + _require_enabled('network') + from plugins.network.models import Subnet + ip_obj = ipaddress.ip_address(query) + subnets = Subnet.query.filter(Subnet.isactive == True).all() + for subnet in subnets: + try: + network = ipaddress.ip_network(subnet.cidr, strict=False) + if ip_obj in network: + results.append({ + 'type': 'subnet', + 'id': subnet.subnetid, + 'title': f'{subnet.name} ({subnet.cidr})', + 'subtitle': subnet.description or subnet.subnettype, + 'url': f'/network', + 'relevance': 70 + }) + except ValueError: + continue + except ImportError: + pass + except Exception as e: + logger.error(f"Subnet search failed: {e}") + return results + + +def _search_hostnames(query, search_term): + """Search hostname fields across Computer, Printer, NetworkDevice.""" + results = [] + + # Search Computers + try: + _require_enabled('computers') + from plugins.computers.models import Computer + computers = Computer.query.filter( + Computer.hostname.ilike(search_term) + ).options( + joinedload(Computer.asset).joinedload(Asset.assettype), + joinedload(Computer.asset).joinedload(Asset.location), + ).limit(10).all() + + for comp in computers: + if comp.asset and comp.asset.isactive: + relevance = 85 if query.lower() == (comp.hostname or '').lower() else 40 + result = _get_asset_result(comp.asset, query, relevance) + result['subtitle'] = comp.hostname + results.append(result) + except ImportError: + pass + except Exception as e: + logger.error(f"Computer hostname search failed: {e}") + + # Search Printers + try: + _require_enabled('printers') + from plugins.printers.models import Printer + printers = Printer.query.filter( + db.or_( + Printer.hostname.ilike(search_term), + Printer.sharename.ilike(search_term), + Printer.windowsname.ilike(search_term), + ) + ).options( + joinedload(Printer.asset).joinedload(Asset.assettype), + joinedload(Printer.asset).joinedload(Asset.location), + ).limit(10).all() + + for printer in printers: + if printer.asset and printer.asset.isactive: + match_field = printer.hostname or printer.sharename or '' + relevance = 85 if query.lower() == match_field.lower() else 40 + result = _get_asset_result(printer.asset, query, relevance) + result['subtitle'] = printer.hostname or printer.sharename + results.append(result) + except ImportError: + pass + except Exception as e: + logger.error(f"Printer hostname search failed: {e}") + + # Search Network Devices + try: + _require_enabled('network') + from plugins.network.models import NetworkDevice + devices = NetworkDevice.query.filter( + NetworkDevice.hostname.ilike(search_term) + ).options( + joinedload(NetworkDevice.asset).joinedload(Asset.assettype), + joinedload(NetworkDevice.asset).joinedload(Asset.location), + ).limit(10).all() + + for device in devices: + if device.asset and device.asset.isactive: + relevance = 85 if query.lower() == (device.hostname or '').lower() else 40 + result = _get_asset_result(device.asset, query, relevance) + result['subtitle'] = device.hostname + results.append(result) + except ImportError: + pass + except Exception as e: + logger.error(f"Network device hostname search failed: {e}") + + return results + + +def _search_notifications(query, search_term): + """Search notifications with time-weighted relevance.""" + results = [] + try: + _require_enabled('notifications') + from plugins.notifications.models import Notification + + notifications = Notification.query.options( + joinedload(Notification.notificationtype) + ).filter( + db.or_( + Notification.notification.ilike(search_term), + Notification.ticketnumber.ilike(search_term) + ) + ).order_by(Notification.starttime.desc()).limit(15).all() + + now = datetime.now(timezone.utc).replace(tzinfo=None) + for notif in notifications: + base_relevance = 20 + if notif.ticketnumber and query.lower() == notif.ticketnumber.lower(): + base_relevance = 85 + + # Time-weighted relevance + if notif.is_current: + base_relevance *= 3 + elif notif.starttime and notif.starttime > now: + base_relevance *= 2 + elif notif.endtime and (now - notif.endtime).days < 7: + base_relevance = int(base_relevance * 1.5) + + results.append({ + 'type': 'notification', + 'id': notif.notificationid, + 'title': notif.title, + 'subtitle': notif.notificationtype.typename if notif.notificationtype else None, + 'url': f'/notifications', + 'relevance': min(int(base_relevance), 100), + 'ticketnumber': notif.ticketnumber, + 'iscurrent': notif.is_current + }) + except ImportError: + pass + except Exception as e: + logger.error(f"Notification search failed: {e}") + return results + + +def _search_vendor_model_type(query, search_term): + """Search assets by vendor name, model name, or machine/device type name.""" + results = [] + + # Machines: vendor, model, machinetype + try: + _require_enabled('machines') + from plugins.machines.models import Machine, MachineType + machine_assets = db.session.query(Asset).join( + Machine, Machine.assetid == Asset.assetid + ).outerjoin( + Vendor, Machine.vendorid == Vendor.vendorid + ).outerjoin( + Model, Machine.modelnumberid == Model.modelnumberid + ).outerjoin( + MachineType, Machine.machinetypeid == MachineType.machinetypeid + ).options( + joinedload(Asset.assettype), + joinedload(Asset.location), + ).filter( + Asset.isactive == True, + db.or_( + Vendor.vendor.ilike(search_term), + Model.modelnumber.ilike(search_term), + MachineType.machinetype.ilike(search_term) + ) + ).limit(10).all() + + for asset in machine_assets: + results.append(_get_asset_result(asset, query, 30)) + except ImportError: + pass + except Exception as e: + logger.error(f"Machine vendor/model/type search failed: {e}") + + # Printers: vendor, model, printertype + try: + _require_enabled('printers') + from plugins.printers.models import Printer, PrinterType + printer_assets = db.session.query(Asset).join( + Printer, Printer.assetid == Asset.assetid + ).outerjoin( + Vendor, Printer.vendorid == Vendor.vendorid + ).outerjoin( + Model, Printer.modelnumberid == Model.modelnumberid + ).outerjoin( + PrinterType, Printer.printertypeid == PrinterType.printertypeid + ).options( + joinedload(Asset.assettype), + joinedload(Asset.location), + ).filter( + Asset.isactive == True, + db.or_( + Vendor.vendor.ilike(search_term), + Model.modelnumber.ilike(search_term), + PrinterType.printertype.ilike(search_term) + ) + ).limit(10).all() + + for asset in printer_assets: + results.append(_get_asset_result(asset, query, 30)) + except ImportError: + pass + except Exception as e: + logger.error(f"Printer vendor/model/type search failed: {e}") + + # Network Devices: vendor, networkdevicetype + try: + _require_enabled('network') + from plugins.network.models import NetworkDevice, NetworkDeviceType + netdev_assets = db.session.query(Asset).join( + NetworkDevice, NetworkDevice.assetid == Asset.assetid + ).outerjoin( + Vendor, NetworkDevice.vendorid == Vendor.vendorid + ).outerjoin( + NetworkDeviceType, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid + ).options( + joinedload(Asset.assettype), + joinedload(Asset.location), + ).filter( + Asset.isactive == True, + db.or_( + Vendor.vendor.ilike(search_term), + NetworkDeviceType.networkdevicetype.ilike(search_term) + ) + ).limit(10).all() + + for asset in netdev_assets: + results.append(_get_asset_result(asset, query, 30)) + except ImportError: + pass + except Exception as e: + logger.error(f"Network device vendor/type search failed: {e}") + + return results + + +def _check_smart_redirect(query, classification): + """Check if query exactly matches a single entity for smart redirect.""" + # Exact SSO match + if classification['is_sso']: + try: + # Shared env-backed connection helper; never hardcode creds. + from shopdb.utils.employee_db import employee_connection + emp_conn = employee_connection() + with emp_conn.cursor() as cur: + cur.execute( + 'SELECT SSO, First_Name, Last_Name FROM employees WHERE SSO = %s LIMIT 1', + (query,) + ) + emp = cur.fetchone() + emp_conn.close() + if emp: + name = f"{emp['First_Name'].strip()} {emp['Last_Name'].strip()}" + return { + 'type': 'employee', + 'url': f"/employees/{emp['SSO']}", + 'label': name + } + except Exception: + pass + + # Exact asset number match + try: + asset = Asset.query.options( + joinedload(Asset.assettype), + ).filter( + Asset.assetnumber == query, + Asset.isactive == True + ).first() + if asset: + result = _get_asset_result(asset, query) + return { + 'type': result['type'], + 'url': result['url'], + 'label': asset.display_name + } + except Exception: + pass + + # Exact printer CSF/share name + try: + _require_enabled('printers') + from plugins.printers.models import Printer + printer = Printer.query.options( + joinedload(Printer.asset).joinedload(Asset.assettype), + ).filter( + db.or_( + Printer.sharename == query, + Printer.windowsname == query + ) + ).first() + if printer and printer.asset and printer.asset.isactive: + return { + 'type': 'printer', + 'url': f"/printers/{printer.printerid}", + 'label': printer.sharename or printer.asset.display_name + } + except ImportError: + pass + except Exception: + pass + + # Exact hostname match (FQDN or bare hostname) + hostname_plugins = [] + try: + _require_enabled('computers') + from plugins.computers.models import Computer + hostname_plugins.append(('computer', Computer, 'computerid', '/pcs')) + except ImportError: + pass + try: + _require_enabled('printers') + from plugins.printers.models import Printer + hostname_plugins.append(('printer', Printer, 'printerid', '/printers')) + except ImportError: + pass + try: + _require_enabled('network') + from plugins.network.models import NetworkDevice + hostname_plugins.append(('network_device', NetworkDevice, 'networkdeviceid', '/network')) + except ImportError: + pass + + for type_name, PluginModel, id_field, url_prefix in hostname_plugins: + try: + device = PluginModel.query.options( + joinedload(PluginModel.asset) + ).filter( + PluginModel.hostname == query + ).first() + if device and device.asset and device.asset.isactive: + return { + 'type': type_name, + 'url': f"{url_prefix}/{getattr(device, id_field)}", + 'label': device.hostname + } + except Exception: + pass + + # Exact IP match + if classification['is_ip']: + try: + comm = Communication.query.options( + joinedload(Communication.asset).joinedload(Asset.assettype) + ).filter( + Communication.ipaddress == query + ).first() + if comm and comm.asset and comm.asset.isactive: + result = _get_asset_result(comm.asset, query) + return { + 'type': result['type'], + 'url': result['url'], + 'label': f"{comm.asset.display_name} ({comm.ipaddress})" + } + except Exception: + pass + + return None + + +@search_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def global_search(): + """ + Global search across multiple entity types. + + Returns combined results from assets, applications, knowledge base, + employees, notifications, IP addresses, hostnames, and vendor/model/type. + Supports smart redirects and ServiceNOW ticket detection. + """ + query = request.args.get('q', '').strip() + + if not query or len(query) < 2: + return success_response({ + 'results': [], + 'query': query, + 'message': 'Search query must be at least 2 characters' + }) + + if len(query) > 200: + return success_response({ + 'results': [], + 'query': query[:200], + 'message': 'Search query too long' + }) + + integrations = _get_search_integrations() + classification = _classify_query(query, integrations) + + # ServiceNOW prefix detection - return redirect immediately + if classification['is_servicenow']: + from urllib.parse import quote + servicenow_url = integrations['servicenow_url'].format(ticket=quote(query)) + return success_response({ + 'results': [], + 'query': query, + 'total': 0, + 'counts': {}, + 'redirect': { + 'type': 'servicenow', + 'url': servicenow_url, + 'label': f'Open {query} in ServiceNOW' + } + }) + + results = [] + search_term = f'%{query}%' + + # Run all search domains + results.extend(_search_applications(query, search_term)) + results.extend(_search_knowledgebase(query, search_term)) + results.extend(_search_employees(query, search_term)) + results.extend(_search_assets(query, search_term)) + results.extend(_search_measuringtools(query, search_term)) + results.extend(_search_customfields(query, search_term)) + results.extend(_search_notifications(query, search_term)) + results.extend(_search_hostnames(query, search_term)) + results.extend(_search_vendor_model_type(query, search_term)) + + # IP-specific searches + if classification['is_ip']: + results.extend(_search_by_ip(query, search_term)) + results.extend(_search_subnets(query)) + + # Sort by relevance (highest first) + results.sort(key=lambda x: x['relevance'], reverse=True) + + # Remove duplicates (prefer higher relevance) + seen_ids = {} + unique_results = [] + for r in results: + key = (r['type'], r['id']) + if key not in seen_ids: + seen_ids[key] = True + unique_results.append(r) + + # Drop result types disabled in Settings > Search (search__enabled). + # Missing key = enabled. One query, default-on. + disabled_types = { + s.key[len('search_'):-len('_enabled')] + for s in Setting.query.filter_by(category='search').all() + if s.get_typed_value() is False + } + if disabled_types: + unique_results = [r for r in unique_results if r['type'] not in disabled_types] + + # Compute type counts before truncation + type_counts = {} + for r in unique_results: + t = r['type'] + type_counts[t] = type_counts.get(t, 0) + 1 + + total_all = len(unique_results) + + # Limit total results + unique_results = unique_results[:50] + + # Check for smart redirect + response_data = { + 'results': unique_results, + 'query': query, + 'total': len(unique_results), + 'total_all': total_all, + 'counts': type_counts, + } + + redirect = _check_smart_redirect(query, classification) + if redirect: + response_data['redirect'] = redirect + + return success_response(response_data) diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py new file mode 100644 index 00000000..5f93cd30 --- /dev/null +++ b/shopdb/core/api/settings.py @@ -0,0 +1,911 @@ +"""Settings API routes.""" + +import os + +from flask import Blueprint, request, current_app, send_from_directory +from flask_jwt_extended import jwt_required, get_jwt_identity +from werkzeug.utils import secure_filename + +from shopdb.extensions import db, cache +from shopdb.core.models import Setting, AuditLog +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +from shopdb.utils.authz import require_permission, require_role + +settings_bp = Blueprint('settings', __name__) + +# Floor-map blueprint uploads live in the instance dir and are served publicly +# (the kiosk dashboards read them without auth). +MAP_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} + +# Branding logo uploads allow everything a map image does plus .ico (favicons). +BRANDING_IMAGE_EXTENSIONS = MAP_IMAGE_EXTENSIONS | {'.ico'} + +# Branding logo kinds and the setting key each one writes. +BRANDING_KIND_SETTINGS = { + 'site': 'site_logo', + 'qr': 'qr_logo', + 'badge': 'badge_logo', + 'favicon': 'site_favicon', +} + + +def _map_dir(): + return os.path.join(current_app.instance_path, 'maps') + + +def _branding_dir(): + return os.path.join(current_app.instance_path, 'branding') + +# Cache key for settings +SETTINGS_CACHE_KEY = 'system_settings' +SETTINGS_CACHE_TTL = 300 # 5 minutes + +# Placeholder returned in API responses for secret values so they are never +# exposed in plaintext. Sending it back on update is treated as "unchanged". +SECRET_MASK = '********' + +# Public-settings allowlist. An UNAUTHENTICATED caller (kiosk dashboards, print +# pages, the login screen, the setup router) may read only these; everything +# else (smtp_host, employee_db_host, zabbix_url, servicenow URLs, ...) needs a +# valid token. Secrets stay masked in both cases. Keep in sync with the keys +# frontend/src/utils/siteSettings.js + mapConfig.js + setupState.js read before +# login. Whole categories that are purely presentation are allowed wholesale; +# the rest are named keys so a new integration key does not leak by default. +PUBLIC_SETTING_CATEGORIES = {'branding', 'map'} +PUBLIC_SETTING_KEYS = { + 'site_base_url', 'facility_name', 'printer_hostname_template', + 'contact_email_domain', 'servicenow_enabled', 'setup_complete', +} + + +def _is_public_setting(setting) -> bool: + return (setting.category in PUBLIC_SETTING_CATEGORIES + or setting.key in PUBLIC_SETTING_KEYS) + +# Optional asset identifiers and the asset types they can be toggled on. +# Drives per-type seed keys and the Settings matrix UI. The asset type names +# match the AssetType.assettype values seeded by each plugin. +IDENTIFIER_LABELS = { + 'gaugelabreference': 'Gauge Lab Reference', + 'maintenancereference': 'Maintenance Reference', + 'fqdn': 'FQDN / hostname', +} +IDENTIFIER_ASSETTYPES = ['machine', 'computer', 'printer', 'network_device', + 'measuring_tool'] + +# Global-search result types that can be toggled on/off independently of whether +# the owning plugin is enabled. Keys match the `type` field on search results; +# seed keys are search__enabled (boolean, default true). Drives the +# Settings "Search" toggles and the filter in shopdb/core/api/search.py. +SEARCH_DOMAINS = { + 'application': 'Applications', + 'knowledgebase': 'Knowledge Base', + 'employee': 'Employees', + 'machine': 'Machines', + 'computer': 'PCs', + 'printer': 'Printers', + 'network_device': 'Network Devices', + 'measuring_tool': 'Measuring Tools', + 'notification': 'Notifications', + 'subnet': 'Subnets', +} + +def _is_secret(key: str) -> bool: + return 'password' in key or 'token' in key or 'secret' in key + + +def _serialize_setting(setting): + """Serialize a setting, masking secret values so they never leave the API.""" + data = setting.to_dict() + if _is_secret(setting.key): + data['value'] = SECRET_MASK if setting.value else '' + return data + + +def get_cached_settings(): + """Get all settings from cache or database.""" + cached = cache.get(SETTINGS_CACHE_KEY) + if cached is not None: + return cached + + settings = Setting.query.all() + result = {s.key: s.get_typed_value() for s in settings} + cache.set(SETTINGS_CACHE_KEY, result, timeout=SETTINGS_CACHE_TTL) + return result + + +def invalidate_settings_cache(): + """Clear the settings cache.""" + cache.delete(SETTINGS_CACHE_KEY) + + +@settings_bp.route('/map-blueprint', methods=['POST']) +@jwt_required() +@require_role('admin') +def upload_map_blueprint(): + """Upload a floor-map blueprint image and point the setting at it. + + multipart/form-data: file=, theme=light|dark. Saves to the instance + maps dir and sets map_blueprint_ to the served URL. + """ + theme = (request.form.get('theme') or '').strip().lower() + if theme not in ('light', 'dark'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'theme must be light or dark') + upload = request.files.get('file') + if not upload or not upload.filename: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') + ext = os.path.splitext(upload.filename)[1].lower() + if ext not in MAP_IMAGE_EXTENSIONS: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Unsupported image type {ext}') + + os.makedirs(_map_dir(), exist_ok=True) + filename = secure_filename(f'blueprint-{theme}{ext}') + upload.save(os.path.join(_map_dir(), filename)) + + url = f'/api/settings/map-blueprint/{filename}' + key = f'map_blueprint_{theme}' + setting = Setting.query.filter_by(key=key).first() + if setting: + setting.value = url + else: + db.session.add(Setting(key=key, value=url, valuetype='string', category='map')) + db.session.commit() + invalidate_settings_cache() + return success_response({'key': key, 'value': url}, message='Blueprint uploaded') + + +@settings_bp.route('/map-blueprint/', methods=['GET']) +def serve_map_blueprint(filename): + """Serve an uploaded blueprint image (public - kiosks read it).""" + return send_from_directory(_map_dir(), filename) + + +@settings_bp.route('/branding-logo', methods=['POST']) +@jwt_required() +@require_role('admin') +def upload_branding_logo(): + """Upload a branding logo image and point the matching setting at it. + + multipart/form-data: file=, kind=site|qr|badge|favicon. Saves to the + instance branding dir and sets the matching branding setting to the served + URL. .ico is accepted in addition to the map image types (for favicons). + """ + kind = (request.form.get('kind') or '').strip().lower() + if kind not in BRANDING_KIND_SETTINGS: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'kind must be one of: ' + ', '.join(sorted(BRANDING_KIND_SETTINGS))) + upload = request.files.get('file') + if not upload or not upload.filename: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') + ext = os.path.splitext(upload.filename)[1].lower() + if ext not in BRANDING_IMAGE_EXTENSIONS: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Unsupported image type {ext}') + + os.makedirs(_branding_dir(), exist_ok=True) + filename = secure_filename(f'logo-{kind}{ext}') + upload.save(os.path.join(_branding_dir(), filename)) + + url = f'/api/settings/branding/{filename}' + key = BRANDING_KIND_SETTINGS[kind] + setting = Setting.query.filter_by(key=key).first() + if setting: + setting.value = url + else: + db.session.add(Setting(key=key, value=url, valuetype='string', category='branding')) + db.session.commit() + invalidate_settings_cache() + return success_response({'key': key, 'value': url}, message='Logo uploaded') + + +@settings_bp.route('/branding/', methods=['GET']) +def serve_branding_logo(filename): + """Serve an uploaded branding logo (public - kiosks/print pages read it).""" + return send_from_directory(_branding_dir(), filename) + + +@settings_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_settings(): + """List all settings, optionally filtered by category.""" + category = request.args.get('category') + + query = Setting.query + if category: + query = query.filter_by(category=category) + + settings = query.order_by(Setting.category, Setting.key).all() + # Unauthenticated callers see only the public allowlist (branding + a few + # bootstrap keys); an authed principal sees everything (secrets masked). + if get_jwt_identity() is None: + settings = [s for s in settings if _is_public_setting(s)] + return success_response([_serialize_setting(s) for s in settings]) + + +@settings_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_setting(key: str): + """Get a single setting by key.""" + setting = Setting.query.filter_by(key=key).first() + + if not setting: + return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404) + + # A non-public key is invisible to an unauthenticated caller (404, not 403, + # so its existence is not confirmed either). + if get_jwt_identity() is None and not _is_public_setting(setting): + return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404) + + return success_response(_serialize_setting(setting)) + + +@settings_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('settings.edit') +def update_setting(key: str): + """Update a setting value.""" + data = request.get_json() + + if data is None or 'value' not in data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'value is required') + + setting = Setting.query.filter_by(key=key).first() + + # Upsert: create the row on first write (e.g. plugin config keys the setup + # wizard saves). New keys default to a plugin-scoped string setting. + if not setting: + setting = Setting(key=key, value='', valuetype='string', category='plugin') + db.session.add(setting) + + # Track old value for audit + old_value = setting.value + + value = data['value'] + + # A secret submitted as the mask placeholder means "leave unchanged" - the + # client only ever received the mask, so don't overwrite the real secret. + if _is_secret(key) and value == SECRET_MASK: + return success_response(_serialize_setting(setting), message='Setting unchanged') + + # Convert value to string for storage + if isinstance(value, bool): + setting.value = 'true' if value else 'false' + else: + setting.value = str(value) if value is not None else None + + # Audit log (mask sensitive values) + is_sensitive = _is_secret(key) + AuditLog.log('updated', 'Setting', entityname=key, changes={ + 'value': { + 'old': '***' if is_sensitive else old_value, + 'new': '***' if is_sensitive else setting.value + } + }) + + db.session.commit() + invalidate_settings_cache() + + return success_response(_serialize_setting(setting), message='Setting updated') + + +@settings_bp.route('/test-email', methods=['POST']) +@jwt_required() +@require_permission('settings.edit') +def test_email(): + """Send a test email to verify SMTP configuration. + + Request: { "to": "addr@example.com" } (falls back to alert_recipients) + + Returns a 200 with a `sent` flag either way. When SMTP is not configured + the response explains that gracefully; when a real send fails the SMTP error + is surfaced with any credential scrubbed out. + """ + from shopdb.utils.mailer import get_smtp_config, render_email, try_send + + data = request.get_json() or {} + config = get_smtp_config() + recipient = data.get('to') or config['alert_recipients'] + + if not config['enabled'] or not config['host']: + return success_response( + {'sent': False, 'reason': 'notconfigured'}, + message='Email is not configured (SMTP disabled or host unset).') + if not recipient: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'No recipient. Provide "to" or set Alert Recipients.') + + html, text = render_email( + 'ShopDB test email', + '

This is a test message confirming your SMTP settings work.

') + ok, error = try_send(recipient, 'ShopDB test email', html, text=text) + if ok: + return success_response({'sent': True}, message='Test email sent.') + return success_response( + {'sent': False, 'error': error}, + message='Test email failed: ' + (error or 'unknown error')) + + +@settings_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('settings.edit') +def create_setting(): + """Create a new setting (admin only).""" + data = request.get_json() + + if not data or not data.get('key'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'key is required') + + if Setting.query.filter_by(key=data['key']).first(): + return error_response(ErrorCodes.CONFLICT, f"Setting '{data['key']}' already exists", http_code=409) + + value = data.get('value') + if isinstance(value, bool): + value_str = 'true' if value else 'false' + else: + value_str = str(value) if value is not None else None + + setting = Setting( + key=data['key'], + value=value_str, + valuetype=data.get('valuetype', 'string'), + category=data.get('category', 'general'), + description=data.get('description') + ) + + db.session.add(setting) + db.session.commit() + invalidate_settings_cache() + + return success_response(setting.to_dict(), message='Setting created', http_code=201) + + +def build_default_settings(): + """Return the full default-settings list (identifier toggles + static). + + Shared by the /settings/seed route and the `flask seed settings` CLI so + the two definitions never drift. + """ + # Asset identifier feature toggles, per identifier AND per asset type. + # Key format: identifier___enabled (boolean). Admins pick + # which optional identifiers show on which asset types. See ADR-001. + identifierdefaults = [ + { + 'key': f'identifier_{name}_{assettype}_enabled', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'identifiers', + 'description': f'Show the {label} identifier on {assettype} assets', + } + for name, label in IDENTIFIER_LABELS.items() + for assettype in IDENTIFIER_ASSETTYPES + ] + + # Per-domain global-search toggles (search__enabled). + searchdefaults = [ + { + 'key': f'search_{key}_enabled', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'search', + 'description': f'Include {label} in global search results', + } + for key, label in SEARCH_DOMAINS.items() + ] + + # Facility floor-map blueprint. Each site instance (ADR-004) points these + # at its own floor-plan image and pixel dimensions; the map frontend reads + # them instead of hardcoding one facility's plan. Defaults are a generic + # placeholder floor plan so an un-reconfigured install still renders. + mapdefaults = [ + { + 'key': 'map_blueprint_light', + 'value': '/static/images/floorplan-placeholder.svg', + 'valuetype': 'string', + 'category': 'map', + 'description': 'Floor-map blueprint image (light theme) for this facility' + }, + { + 'key': 'map_blueprint_dark', + 'value': '/static/images/floorplan-placeholder.svg', + 'valuetype': 'string', + 'category': 'map', + 'description': 'Floor-map blueprint image (dark theme) for this facility' + }, + { + 'key': 'map_width', + 'value': '3300', + 'valuetype': 'integer', + 'category': 'map', + 'description': 'Floor-map blueprint width in pixels (native size of the image)' + }, + { + 'key': 'map_height', + 'value': '2550', + 'valuetype': 'integer', + 'category': 'map', + 'description': 'Floor-map blueprint height in pixels (native size of the image)' + }, + ] + + # Site identity. Each instance (ADR-004) sets its own public URL - used for + # QR codes and any absolute link the app emits - and facility name shown on + # the shopfloor dashboard. Blank site_base_url falls back to the browsing + # origin so nothing breaks before a site configures it. + sitedefaults = [ + { + 'key': 'setup_complete', + 'value': 'false', + 'valuetype': 'boolean', + 'category': 'site', + 'description': 'Set true once the first-run setup wizard has been finished' + }, + { + 'key': 'employee_directory_mode', + 'value': 'selfhosted', + 'valuetype': 'string', + 'category': 'site', + 'description': "Employee directory source: 'selfhosted' (tables in this app, default) or 'external' (a separate HR database)" + }, + { + 'key': 'usb_directory_mode', + 'value': 'selfhosted', + 'valuetype': 'string', + 'category': 'site', + 'description': "USB check-in/out source: 'selfhosted' (tables in this app, default) or 'external' (a separate cmmc_usb database)" + }, + { + 'key': 'site_base_url', + 'value': '', + 'valuetype': 'string', + 'category': 'site', + 'description': 'Public base URL of this site (scheme + host), e.g. https://shopdb.example.net. Used for QR codes and absolute links. Blank = use the browsing origin.' + }, + { + 'key': 'facility_name', + 'value': '', + 'valuetype': 'string', + 'category': 'site', + 'description': 'Facility name shown on the shopfloor dashboard header (blank = frontend falls back to ShopDB)' + }, + { + 'key': 'pc_access_domain', + 'value': 'device.geaerospace.net', + 'valuetype': 'string', + 'category': 'site', + 'description': 'Domain appended to a PC hostname to build remote-access links (host.device.geaerospace.net). Blank = use the hostname as-is.' + }, + { + 'key': 'employeeid_pattern', + 'value': r'^\d{9}$', + 'valuetype': 'string', + 'category': 'site', + 'description': 'Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never errors.' + }, + { + 'key': 'dualpath_single_machine', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'site', + 'description': 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. The data model always keeps both bay records; detail pages stay per-bay with a sibling banner. Off = list and count both bays separately.' + }, + { + 'key': 'printer_hostname_template', + 'value': 'Printer-{ip}.printer.geaerospace.net', + 'valuetype': 'string', + 'category': 'site', + 'description': 'Template for a printer hostname built from its IP. {ip} is the dash-separated IP address.' + }, + { + 'key': 'contact_email_domain', + 'value': 'geaerospace.com', + 'valuetype': 'string', + 'category': 'site', + 'description': 'Email domain appended to a contact SSO to build email/Teams links (sso@domain). Blank disables contact action buttons.' + }, + ] + + # Site branding. Each instance (ADR-004) can replace the shipped GE defaults + # with its own logos, favicon, and primary color. Blank values fall back to + # the built-in shipped assets so an un-reconfigured install still renders. + brandingdefaults = [ + { + 'key': 'site_logo', + 'value': '/ge-aerospace-logo.svg', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Main site logo shown in the app header and login page' + }, + { + 'key': 'qr_logo', + 'value': '/ge-monogram.svg', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Logo composited in the center of printer QR labels (blank = no QR overlay)' + }, + { + 'key': 'badge_logo', + 'value': '/ge-aerospace-logo.svg', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Logo shown on the machine badge print page' + }, + { + 'key': 'site_favicon', + 'value': '', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Browser tab favicon (blank = shipped /favicon.svg)' + }, + { + 'key': 'brand_primary_color', + 'value': '', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Primary brand color as a CSS color value (blank = built-in theme color)' + }, + { + 'key': 'brand_primary_dark_color', + 'value': '', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Primary hover/active color (blank = derived by darkening the primary color ~15%)' + }, + { + 'key': 'brand_accent_color', + 'value': '', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Accent color for secondary buttons and badges (blank = built-in theme color)' + }, + { + 'key': 'brand_sidebar_color', + 'value': '', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Sidebar background color (blank = built-in theme color)' + }, + ] + + # Printed QR/label targets. Blank template = QR links to the asset's own + # detail page on this instance; a non-blank value is a URL template with + # {placeholder} substitution so a site can point labels anywhere. + printingdefaults = [ + { + 'key': 'qr_target_printer', + 'value': '', + 'valuetype': 'string', + 'category': 'printing', + 'description': 'Custom URL template for printer QR labels. Blank = link to the printer page. Placeholders: {printerid}, {assetid}, {assetnumber}, {serialnumber}, {ip}, {hostname}.' + }, + { + 'key': 'qr_target_usb', + 'value': '', + 'valuetype': 'string', + 'category': 'printing', + 'description': 'Custom URL template for USB label QR codes. Blank = link to the USB device page. Placeholders: {id}, {serialnumber}, {alias}.' + }, + { + 'key': 'usb_label_style', + 'value': 'barcode', + 'valuetype': 'string', + 'category': 'printing', + 'description': "USB mini-label code style: 'barcode' (CODE128 of the serial number) or 'qr' (QR code linking to the QR target)." + }, + { + 'key': 'qr_target_machine', + 'value': '', + 'valuetype': 'string', + 'category': 'printing', + 'description': 'Custom URL template for machine labels. Blank = link to the machine page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.' + }, + { + 'key': 'qr_target_computer', + 'value': '', + 'valuetype': 'string', + 'category': 'printing', + 'description': 'Custom URL template for computer labels. Blank = link to the computer page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.' + }, + { + 'key': 'qr_target_network_device', + 'value': '', + 'valuetype': 'string', + 'category': 'printing', + 'description': 'Custom URL template for network-device labels. Blank = link to the device page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.' + }, + { + 'key': 'qr_target_measuring_tool', + 'value': '', + 'valuetype': 'string', + 'category': 'printing', + 'description': 'Custom URL template for measuring-tool labels. Blank = link to the tool page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}, {locationcode}, {locationname}.' + }, + { + 'key': 'label_default_style', + 'value': 'card', + 'valuetype': 'string', + 'category': 'printing', + 'description': "Default asset-label layout: 'card' (badge with image and identity) or 'plain' (just the code and a caption)." + }, + { + 'key': 'label_default_codetype', + 'value': 'qr', + 'valuetype': 'string', + 'category': 'printing', + 'description': "Default asset-label code type: 'qr' (QR code) or 'barcode' (CODE128)." + }, + ] + + # Per-asset-type default for what a label's code encodes. Machines default + # to their machine number (assetnumber), measuring tools to their inspection + # location code, everything else to a link to the asset page. Values: + # assetpage | assetnumber | serialnumber | location | custom. + labelencodesdefaults = { + 'machine': 'assetnumber', + 'computer': 'assetpage', + 'printer': 'assetpage', + 'network_device': 'assetpage', + 'measuring_tool': 'location', + } + printingdefaults += [ + { + 'key': f'label_default_encodes_{assettype}', + 'value': value, + 'valuetype': 'string', + 'category': 'printing', + 'description': f'What a {assettype} label encodes by default: assetpage, ' + 'assetnumber, serialnumber' + + (', location' if assettype == 'measuring_tool' else '') + + ', or custom.', + } + for assettype, value in labelencodesdefaults.items() + ] + + # Collector pc-type -> ComputerType mapping is computers-plugin domain; + # the plugin seeds pctypemap_ settings on install. + defaults = sitedefaults + brandingdefaults + printingdefaults + identifierdefaults + searchdefaults + mapdefaults + [ + # ServiceNow ticket links. Each instance points these at its own + # ServiceNow tenant; blank/disabled renders tickets as plain text. + { + 'key': 'servicenow_enabled', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'integrations', + 'description': 'Enable ServiceNow ticket recognition and links in search and dashboards' + }, + { + 'key': 'servicenow_search_url', + 'value': ( + 'https://geaerospaceqa.service-now.com/now/nav/ui/search/' + '0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/' + 'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/' + ), + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'ServiceNow global-search URL template. {ticket} is the ticket number.' + }, + { + 'key': 'servicenow_ticket_prefixes', + 'value': 'GEINC,GECHG,GERIT,GESCT', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'Comma-separated ticket-number prefixes recognized as ServiceNow tickets' + }, + { + 'key': 'servicenow_incident_url', + 'value': 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'ServiceNow incident URL template. {ticket} is the incident number.' + }, + { + 'key': 'servicenow_change_url', + 'value': 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'ServiceNow change-request URL template. {ticket} is the change number.' + }, + # Zabbix integration + { + 'key': 'zabbix_enabled', + 'value': 'false', + 'valuetype': 'boolean', + 'category': 'integrations', + 'description': 'Enable Zabbix integration for printer supply monitoring' + }, + { + 'key': 'zabbix_url', + 'value': '', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'Zabbix API URL (e.g., http://zabbix.example.com:8080)' + }, + { + 'key': 'zabbix_token', + 'value': '', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'Zabbix API authentication token' + }, + # Dell warranty lookup (Dell TechDirect Warranty API, OAuth2) + { + 'key': 'warranty_dell_enabled', + 'value': 'false', + 'valuetype': 'boolean', + 'category': 'integrations', + 'description': 'Enable Dell warranty lookups (service-tag entitlements)' + }, + { + 'key': 'warranty_dell_clientid', + 'value': '', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'Dell TechDirect API client id' + }, + { + 'key': 'warranty_dell_clientsecret', + 'value': '', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'Dell TechDirect API client secret' + }, + { + 'key': 'warranty_dell_tokenurl', + 'value': '', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'Dell OAuth token URL (blank = Dell default)' + }, + { + 'key': 'warranty_dell_apiurl', + 'value': '', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'Dell warranty API URL (blank = Dell default)' + }, + # Email/SMTP settings + { + 'key': 'smtp_enabled', + 'value': 'false', + 'valuetype': 'boolean', + 'category': 'email', + 'description': 'Enable email notifications and alerts' + }, + { + 'key': 'smtp_host', + 'value': '', + 'valuetype': 'string', + 'category': 'email', + 'description': 'SMTP server hostname' + }, + { + 'key': 'smtp_port', + 'value': '587', + 'valuetype': 'integer', + 'category': 'email', + 'description': 'SMTP server port (usually 587 for TLS, 465 for SSL, 25 for unencrypted)' + }, + { + 'key': 'smtp_username', + 'value': '', + 'valuetype': 'string', + 'category': 'email', + 'description': 'SMTP authentication username' + }, + { + 'key': 'smtp_password', + 'value': '', + 'valuetype': 'string', + 'category': 'email', + 'description': 'SMTP authentication password' + }, + { + 'key': 'smtp_use_tls', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'email', + 'description': 'Use TLS encryption for SMTP connection' + }, + { + 'key': 'smtp_from_address', + 'value': '', + 'valuetype': 'string', + 'category': 'email', + 'description': 'From address for outgoing emails' + }, + { + 'key': 'smtp_from_name', + 'value': 'ShopDB', + 'valuetype': 'string', + 'category': 'email', + 'description': 'From name for outgoing emails' + }, + { + 'key': 'alert_recipients', + 'value': '', + 'valuetype': 'string', + 'category': 'email', + 'description': 'Default email recipients for alerts (comma-separated)' + }, + # Audit log settings + { + 'key': 'audit_retention_days', + 'value': '90', + 'valuetype': 'integer', + 'category': 'audit', + 'description': 'Number of days to retain audit logs (0 = keep forever)' + }, + # Authentication settings + { + 'key': 'saml_enabled', + 'value': 'false', + 'valuetype': 'boolean', + 'category': 'auth', + 'description': 'Enable SAML SSO authentication' + }, + { + 'key': 'saml_idp_metadata_url', + 'value': '', + 'valuetype': 'string', + 'category': 'auth', + 'description': 'SAML Identity Provider metadata URL' + }, + { + 'key': 'saml_entity_id', + 'value': '', + 'valuetype': 'string', + 'category': 'auth', + 'description': 'SAML Service Provider entity ID (e.g., https://shopdb.example.com)' + }, + { + 'key': 'saml_acs_url', + 'value': '', + 'valuetype': 'string', + 'category': 'auth', + 'description': 'SAML Assertion Consumer Service URL' + }, + { + 'key': 'saml_allow_local_login', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'auth', + 'description': 'Allow local username/password login when SAML is enabled' + }, + { + 'key': 'saml_auto_create_users', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'auth', + 'description': 'Automatically create users on first SAML login' + }, + { + 'key': 'saml_admin_group', + 'value': '', + 'valuetype': 'string', + 'category': 'auth', + 'description': 'SAML group name that grants admin role' + }, + ] + + return defaults + + +@settings_bp.route('/seed', methods=['POST']) +@jwt_required() +@require_permission('settings.edit') +def seed_default_settings(): + """Seed default settings if they don't exist.""" + created = 0 + for d in build_default_settings(): + if not Setting.query.filter_by(key=d['key']).first(): + setting = Setting(**d) + db.session.add(setting) + created += 1 + + db.session.commit() + invalidate_settings_cache() + + return success_response({'created': created}, message=f'{created} default settings created') diff --git a/shopdb/core/api/setup.py b/shopdb/core/api/setup.py new file mode 100644 index 00000000..ff748294 --- /dev/null +++ b/shopdb/core/api/setup.py @@ -0,0 +1,114 @@ +"""First-run setup wizard support endpoints. + +The wizard itself is frontend; these endpoints cover the pieces that need +server work: seeding common starter data and marking setup finished. Site, +plugin, and map configuration reuse the existing settings / plugins APIs. +""" + +from flask import Blueprint, request, current_app +from flask_jwt_extended import jwt_required +from werkzeug.security import generate_password_hash + +from shopdb.extensions import db +from shopdb.core.models import Vendor, Setting, User, Role +from shopdb.utils.responses import success_response, error_response, ErrorCodes +from shopdb.utils.authz import require_role + +setup_bp = Blueprint('setup', __name__) + +# Common hardware vendors most sites will want on hand. +STARTER_VENDORS = [ + ('Dell Inc.', 'https://www.dell.com'), + ('HP Inc.', 'https://www.hp.com'), + ('Lenovo', 'https://www.lenovo.com'), + ('Xerox', 'https://www.xerox.com'), + ('Zebra Technologies', 'https://www.zebra.com'), + ('Cisco', 'https://www.cisco.com'), + ('Brother', 'https://www.brother.com'), + ('Microsoft', 'https://www.microsoft.com'), +] + + +@setup_bp.route('/needs-admin', methods=['GET']) +def needs_admin(): + """True when no users exist yet - the app's very first run. Public so the + login screen can offer to create the first admin.""" + return success_response({'needsadmin': User.query.count() == 0}) + + +@setup_bp.route('/create-admin', methods=['POST']) +def create_admin(): + """Create the first admin. Only works while there are zero users, so it + can't be abused after setup. No auth (there is no one to auth as yet).""" + if User.query.count() > 0: + return error_response(ErrorCodes.FORBIDDEN, + 'An account already exists; sign in instead.', http_code=403) + data = request.get_json() or {} + username = (data.get('username') or '').strip() + email = (data.get('email') or '').strip() + password = data.get('password') or '' + if not (username and email and password): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'username, email and password are required') + + admin_role = Role.query.filter_by(rolename='admin').first() + if not admin_role: + admin_role = Role(rolename='admin', description='Administrator') + db.session.add(admin_role) + + user = User(username=username, email=email, + passwordhash=generate_password_hash(password), isactive=True) + user.roles.append(admin_role) + db.session.add(user) + db.session.commit() + return success_response({'username': username}, message='Admin created', http_code=201) + + +@setup_bp.route('/seed-reference', methods=['POST']) +@jwt_required() +@require_role('admin') +def seed_reference(): + """Seed core reference data + permissions + default settings. Idempotent - + reuses the same routines as the `flask seed ...` CLI commands.""" + # Invoke the seed CLI commands in a proper click context via the runner. + runner = current_app.test_cli_runner() + for command in ('reference-data', 'permissions', 'settings'): + result = runner.invoke(args=['seed', command]) + if result.exit_code not in (0, None): + return error_response(ErrorCodes.INTERNAL_ERROR, + f'seed {command} failed: {result.output}', http_code=500) + return success_response(message='Reference data, permissions and settings seeded.') + + +@setup_bp.route('/seed-starter', methods=['POST']) +@jwt_required() +@require_role('admin') +def seed_starter(): + """Add common vendors that are not already present. Idempotent.""" + existing = {v.vendor.strip().lower() for v in Vendor.query.all()} + added = [] + for name, website in STARTER_VENDORS: + if name.strip().lower() in existing: + continue + db.session.add(Vendor(vendor=name, website=website)) + added.append(name) + db.session.commit() + return success_response( + {'added': added, 'addedcount': len(added)}, + message=f'Seeded {len(added)} vendor(s).' + ) + + +@setup_bp.route('/complete', methods=['POST']) +@jwt_required() +@require_role('admin') +def mark_complete(): + """Flag the first-run setup as finished.""" + row = Setting.query.filter_by(key='setup_complete').first() + if row: + row.value = 'true' + else: + db.session.add(Setting(key='setup_complete', value='true', + valuetype='boolean', category='site')) + db.session.commit() + return success_response({'complete': True}, message='Setup marked complete.') diff --git a/shopdb/core/api/supportteams.py b/shopdb/core/api/supportteams.py new file mode 100644 index 00000000..918d5cd4 --- /dev/null +++ b/shopdb/core/api/supportteams.py @@ -0,0 +1,219 @@ +"""Support Teams API endpoints - teams and their nested contacts.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import ( + SupportTeam, SupportTeamContact, Application, AuditLog +) +from shopdb.utils.responses import ( + success_response, + error_response, + ErrorCodes +) +from shopdb.utils.authz import require_role +from shopdb.utils.import_mode import apply_import_timestamps + +supportteams_bp = Blueprint('supportteams', __name__) + + +@supportteams_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_support_teams(): + """List support teams (with contacts). ?active and ?teamname filters.""" + query = SupportTeam.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(SupportTeam.isactive == True) + + # Exact-match natural-key lookup for idempotent import (team name). + if exactteamname := request.args.get('teamname'): + query = query.filter(SupportTeam.teamname == exactteamname) + + teams = query.order_by(SupportTeam.teamname).all() + return success_response([team.to_dict() for team in teams]) + + +@supportteams_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_support_team(team_id: int): + """Get a single support team with its contacts.""" + team = db.session.get(SupportTeam, team_id) + if not team: + return error_response(ErrorCodes.NOT_FOUND, 'Support team not found', + http_code=404) + return success_response(team.to_dict()) + + +@supportteams_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_support_team(): + """Create a new support team.""" + data = request.get_json() + if not data or not data.get('teamname'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'teamname is required') + + if SupportTeam.query.filter_by(teamname=data['teamname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Support team '{data['teamname']}' already exists", + http_code=409) + + team = SupportTeam( + teamname=data['teamname'], + teamurl=data.get('teamurl'), + isactive=data.get('isactive', True)) + + db.session.add(team) + apply_import_timestamps(team, data) + db.session.flush() + + AuditLog.log('created', 'SupportTeam', entityid=team.supportteamid, + entityname=team.teamname) + db.session.commit() + + return success_response(team.to_dict(), message='Support team created', + http_code=201) + + +@supportteams_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_support_team(team_id: int): + """Update a support team.""" + team = db.session.get(SupportTeam, team_id) + if not team: + return error_response(ErrorCodes.NOT_FOUND, 'Support team not found', + http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'teamname' in data and data['teamname'] != team.teamname: + if SupportTeam.query.filter_by(teamname=data['teamname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Support team '{data['teamname']}' already exists", + http_code=409) + + for key in ['teamname', 'teamurl', 'isactive']: + if key in data: + setattr(team, key, data[key]) + + apply_import_timestamps(team, data) + AuditLog.log('updated', 'SupportTeam', entityid=team.supportteamid, + entityname=team.teamname) + db.session.commit() + + return success_response(team.to_dict(), message='Support team updated') + + +@supportteams_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_support_team(team_id: int): + """Delete a support team; 409 while any application references it.""" + team = db.session.get(SupportTeam, team_id) + if not team: + return error_response(ErrorCodes.NOT_FOUND, 'Support team not found', + http_code=404) + + refcount = Application.query.filter_by(supportteamid=team_id).count() + if refcount: + return error_response( + ErrorCodes.CONFLICT, + f'{refcount} application(s) still reference this support team', + http_code=409) + + AuditLog.log('deleted', 'SupportTeam', entityid=team.supportteamid, + entityname=team.teamname) + db.session.delete(team) # cascade removes its contacts + db.session.commit() + + return success_response(message='Support team deleted') + + +# ---- Contacts (nested under a team) ---- + +@supportteams_bp.route('//contacts', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_contact(team_id: int): + """Add a contact to a support team.""" + team = db.session.get(SupportTeam, team_id) + if not team: + return error_response(ErrorCodes.NOT_FOUND, 'Support team not found', + http_code=404) + + data = request.get_json() + if not data or not data.get('name'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') + + contact = SupportTeamContact( + supportteamid=team_id, + name=data['name'], + sso=data.get('sso'), + sortorder=data.get('sortorder', 0), + isactive=data.get('isactive', True)) + + db.session.add(contact) + apply_import_timestamps(contact, data) + db.session.flush() + + AuditLog.log('created', 'SupportTeamContact', entityid=contact.contactid, + entityname=contact.name) + db.session.commit() + + return success_response(contact.to_dict(), message='Contact created', + http_code=201) + + +@supportteams_bp.route('//contacts/', + methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_contact(team_id: int, contact_id: int): + """Update a support-team contact.""" + contact = SupportTeamContact.query.filter_by( + contactid=contact_id, supportteamid=team_id).first() + if not contact: + return error_response(ErrorCodes.NOT_FOUND, 'Contact not found', + http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + for key in ['name', 'sso', 'sortorder', 'isactive']: + if key in data: + setattr(contact, key, data[key]) + + apply_import_timestamps(contact, data) + AuditLog.log('updated', 'SupportTeamContact', entityid=contact.contactid, + entityname=contact.name) + db.session.commit() + + return success_response(contact.to_dict(), message='Contact updated') + + +@supportteams_bp.route('//contacts/', + methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_contact(team_id: int, contact_id: int): + """Delete a support-team contact.""" + contact = SupportTeamContact.query.filter_by( + contactid=contact_id, supportteamid=team_id).first() + if not contact: + return error_response(ErrorCodes.NOT_FOUND, 'Contact not found', + http_code=404) + + AuditLog.log('deleted', 'SupportTeamContact', entityid=contact.contactid, + entityname=contact.name) + db.session.delete(contact) + db.session.commit() + + return success_response(message='Contact deleted') diff --git a/shopdb/core/api/users.py b/shopdb/core/api/users.py new file mode 100644 index 00000000..84f9dac1 --- /dev/null +++ b/shopdb/core/api/users.py @@ -0,0 +1,389 @@ +"""User management API routes.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required, current_user +from werkzeug.security import generate_password_hash + +from shopdb.extensions import db +from shopdb.core.models import ( + User, Role, Permission, AuditLog, full_permission_catalog) +from shopdb.utils.responses import success_response, error_response, ErrorCodes +from shopdb.utils.authz import require_role + +users_bp = Blueprint('users', __name__) + + +@users_bp.route('', methods=['GET']) +@jwt_required() +@require_role('admin') +def list_users(): + """List all users.""" + users = User.query.order_by(User.username).all() + return success_response([user_to_dict(u) for u in users]) + + +@users_bp.route('/', methods=['GET']) +@jwt_required() +def get_user(userid: int): + """Get a single user.""" + # inline: decorators cannot express admin-or-self + if not current_user.hasrole('admin') and current_user.userid != userid: + return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403) + + user = db.session.get(User, userid) + if not user: + return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404) + + return success_response(user_to_dict(user)) + + +@users_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_user(): + """Create a new user.""" + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required') + + # Validate required fields + if not data.get('username'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Username is required') + if not data.get('email'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Email is required') + if not data.get('password'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Password is required') + + # Check uniqueness + if User.query.filter_by(username=data['username']).first(): + return error_response(ErrorCodes.CONFLICT, 'Username already exists', http_code=409) + if User.query.filter_by(email=data['email']).first(): + return error_response(ErrorCodes.CONFLICT, 'Email already exists', http_code=409) + + # Admin-created accounts are forced to change the password on first login + # unless the admin explicitly opts out. + mustchange = data.get('mustchangepassword', True) + + user = User( + username=data['username'], + email=data['email'], + passwordhash=generate_password_hash(data['password']), + firstname=data.get('firstname'), + lastname=data.get('lastname'), + isactive=data.get('isactive', True), + mustchangepassword=bool(mustchange) + ) + + # Assign roles + role_ids = data.get('roles', []) + if role_ids: + roles = Role.query.filter(Role.roleid.in_(role_ids)).all() + user.roles = roles + + db.session.add(user) + + # Audit log + AuditLog.log('created', 'User', entityname=user.username) + + db.session.commit() + + # Best-effort welcome email. The account exists regardless of mail outcome; + # a failure is surfaced as a warning in the response, never a hard error. + warning = None + if data.get('sendwelcome', True) and user.email: + sent = _send_welcome_email(user, data['password']) + if not sent: + warning = 'User created but the welcome email could not be sent.' + + payload = user_to_dict(user) + if warning: + payload['warning'] = warning + return success_response(payload, message='User created', http_code=201) + + +def _send_welcome_email(user, temp_password): + """Send a new-user welcome email with sign-in details. Returns True on send. + + Best-effort: any failure (including email being disabled) returns False so + the caller can surface a soft warning without failing user creation. + """ + from shopdb.core.api.settings import get_cached_settings + from shopdb.utils.mailer import render_email, send_email + + settings = get_cached_settings() or {} + facility = settings.get('facility_name') or 'ShopDB' + base_url = (settings.get('site_base_url') or '').rstrip('/') + login_link = f'{base_url}/login' if base_url else 'the ShopDB sign-in page' + + body = ( + f'

An account has been created for you at {facility}.

' + '
' + f'' + f'' + f'' + f'' + '
Username{user.username}
Temporary password{temp_password}
' + f'

Sign in at {login_link}. You will be asked to set a new password ' + 'the first time you log in.

' + ) + html, text = render_email(f'Welcome to {facility}', body) + return send_email(user.email, f'Your {facility} account', html, text=text) + + +@users_bp.route('/', methods=['PUT']) +@jwt_required() +def update_user(userid: int): + """Update a user.""" + # inline: decorators cannot express admin-or-self + if not current_user.hasrole('admin') and current_user.userid != userid: + return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403) + + user = db.session.get(User, userid) + if not user: + return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required') + + changes = {} + + # Update fields + if 'email' in data and data['email'] != user.email: + if User.query.filter(User.email == data['email'], User.userid != userid).first(): + return error_response(ErrorCodes.CONFLICT, 'Email already in use', http_code=409) + changes['email'] = {'old': user.email, 'new': data['email']} + user.email = data['email'] + + if 'firstname' in data: + if data['firstname'] != user.firstname: + changes['firstname'] = {'old': user.firstname, 'new': data['firstname']} + user.firstname = data['firstname'] + + if 'lastname' in data: + if data['lastname'] != user.lastname: + changes['lastname'] = {'old': user.lastname, 'new': data['lastname']} + user.lastname = data['lastname'] + + # Admin-only fields (inline: gates a subset of fields on a shared route) + if current_user.hasrole('admin'): + if 'isactive' in data: + if data['isactive'] != user.isactive: + changes['isactive'] = {'old': user.isactive, 'new': data['isactive']} + user.isactive = data['isactive'] + + if 'roles' in data: + old_roles = [r.rolename for r in user.roles] + roles = Role.query.filter(Role.roleid.in_(data['roles'])).all() + new_roles = [r.rolename for r in roles] + if set(old_roles) != set(new_roles): + changes['roles'] = {'old': old_roles, 'new': new_roles} + user.roles = roles + + # Unlock user + if data.get('unlock'): + user.lockeduntil = None + user.failedlogins = 0 + changes['unlocked'] = {'old': True, 'new': False} + + # Password change + if 'password' in data and data['password']: + user.passwordhash = generate_password_hash(data['password']) + changes['password'] = {'old': '***', 'new': '***'} + + if changes: + AuditLog.log('updated', 'User', entityid=user.userid, entityname=user.username, changes=changes) + + db.session.commit() + + return success_response(user_to_dict(user), message='User updated') + + +@users_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_user(userid: int): + """Delete a user.""" + if current_user.userid == userid: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete your own account') + + user = db.session.get(User, userid) + if not user: + return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404) + + username = user.username + db.session.delete(user) + + AuditLog.log('deleted', 'User', entityid=userid, entityname=username) + + db.session.commit() + + return success_response(None, message='User deleted') + + +# Permissions endpoints +@users_bp.route('/permissions', methods=['GET']) +@jwt_required() +def list_permissions(): + """List assignable permissions grouped by category. + + Driven by full_permission_catalog() (core plus ENABLED plugins) so a + disabled plugin's permissions drop out of the role grid. Roles assign by + name; the permissionid comes from the seeded Permission row when present. + """ + idbyname = {p.name: p.permissionid for p in Permission.query.all()} + + catalog = full_permission_catalog() + catalog.sort(key=lambda e: (e[2], e[0])) + + grouped = {} + flat = [] + for name, description, category in catalog: + entry = { + 'permissionid': idbyname.get(name), + 'name': name, + 'description': description, + } + grouped.setdefault(category, []).append(entry) + flat.append({**entry, 'category': category}) + + return success_response({'permissions': flat, 'grouped': grouped}) + + +# Roles endpoints +@users_bp.route('/roles', methods=['GET']) +@jwt_required() +def list_roles(): + """List all roles with their permissions.""" + roles = Role.query.order_by(Role.rolename).all() + return success_response([{ + 'roleid': r.roleid, + 'rolename': r.rolename, + 'description': r.description, + 'usercount': r.users.count(), + 'permissions': [p.name for p in r.permissions], + 'isadmin': r.rolename == 'admin' + } for r in roles]) + + +@users_bp.route('/roles', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_role(): + """Create a new role.""" + data = request.get_json() + if not data or not data.get('rolename'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Role name is required') + + if Role.query.filter_by(rolename=data['rolename']).first(): + return error_response(ErrorCodes.CONFLICT, 'Role already exists', http_code=409) + + role = Role( + rolename=data['rolename'], + description=data.get('description') + ) + + # Assign permissions + if 'permissions' in data: + perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all() + role.permissions = perms + + db.session.add(role) + + AuditLog.log('created', 'Role', entityname=role.rolename) + + db.session.commit() + + return success_response({ + 'roleid': role.roleid, + 'rolename': role.rolename, + 'description': role.description, + 'permissions': [p.name for p in role.permissions] + }, message='Role created', http_code=201) + + +@users_bp.route('/roles/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_role(roleid: int): + """Update a role.""" + role = db.session.get(Role, roleid) + if not role: + return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404) + + # Cannot modify admin role permissions + if role.rolename == 'admin' and 'permissions' in request.get_json(): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot modify admin role permissions') + + data = request.get_json() + changes = {} + + if 'description' in data: + if data['description'] != role.description: + changes['description'] = {'old': role.description, 'new': data['description']} + role.description = data['description'] + + # Update permissions + if 'permissions' in data and role.rolename != 'admin': + old_perms = [p.name for p in role.permissions] + perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all() + new_perms = [p.name for p in perms] + if set(old_perms) != set(new_perms): + changes['permissions'] = {'old': old_perms, 'new': new_perms} + role.permissions = perms + + if changes: + AuditLog.log('updated', 'Role', entityid=role.roleid, entityname=role.rolename, changes=changes) + + db.session.commit() + + return success_response({ + 'roleid': role.roleid, + 'rolename': role.rolename, + 'description': role.description, + 'permissions': [p.name for p in role.permissions] + }, message='Role updated') + + +@users_bp.route('/roles/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_role(roleid: int): + """Delete a role.""" + role = db.session.get(Role, roleid) + if not role: + return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404) + + if role.rolename == 'admin': + return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete the admin role') + + if role.users.count() > 0: + return error_response(ErrorCodes.VALIDATION_ERROR, f'Role is assigned to {role.users.count()} user(s)') + + rolename = role.rolename + db.session.delete(role) + + AuditLog.log('deleted', 'Role', entityid=roleid, entityname=rolename) + + db.session.commit() + + return success_response(None, message='Role deleted') + + +def user_to_dict(user: User) -> dict: + """Convert user to dict for API response.""" + return { + 'userid': user.userid, + 'username': user.username, + 'email': user.email, + 'firstname': user.firstname, + 'lastname': user.lastname, + 'isactive': user.isactive, + 'islocked': user.islocked, + 'mustchangepassword': bool(user.mustchangepassword), + 'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None, + 'failedlogins': user.failedlogins, + 'roles': [{'roleid': r.roleid, 'rolename': r.rolename} for r in user.roles], + 'createddate': user.createddate.isoformat() + 'Z' if user.createddate else None, + 'modifieddate': user.modifieddate.isoformat() + 'Z' if user.modifieddate else None + } diff --git a/shopdb/core/api/vendors.py b/shopdb/core/api/vendors.py new file mode 100644 index 00000000..6c1104a9 --- /dev/null +++ b/shopdb/core/api/vendors.py @@ -0,0 +1,149 @@ +"""Vendors API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Vendor +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from shopdb.utils.authz import require_role +from shopdb.utils.import_mode import apply_import_timestamps + +vendors_bp = Blueprint('vendors', __name__) + + +@vendors_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_vendors(): + """List all vendors.""" + page, per_page = get_pagination_params(request) + + query = Vendor.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Vendor.isactive == True) + + # Exact-match natural-key lookup for idempotent import (vendor name). + if exactvendor := request.args.get('vendor'): + query = query.filter(Vendor.vendor == exactvendor) + + if search := request.args.get('search'): + query = query.filter(Vendor.vendor.ilike(f'%{search}%')) + + query = query.order_by(Vendor.vendor) + + items, total = paginate_query(query, page, per_page) + data = [v.to_dict() for v in items] + + return paginated_response(data, page, per_page, total) + + +@vendors_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_vendor(vendor_id: int): + """Get a single vendor.""" + v = db.session.get(Vendor, vendor_id) + + if not v: + return error_response( + ErrorCodes.NOT_FOUND, + f'Vendor with ID {vendor_id} not found', + http_code=404 + ) + + return success_response(v.to_dict()) + + +@vendors_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_vendor(): + """Create a new vendor.""" + data = request.get_json() + + if not data or not data.get('vendor'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'vendor is required') + + if Vendor.query.filter_by(vendor=data['vendor']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Vendor '{data['vendor']}' already exists", + http_code=409 + ) + + v = Vendor( + vendor=data['vendor'], + description=data.get('description'), + website=data.get('website'), + supportphone=data.get('supportphone'), + supportemail=data.get('supportemail'), + notes=data.get('notes') + ) + + db.session.add(v) + apply_import_timestamps(v, data) + db.session.commit() + + return success_response(v.to_dict(), message='Vendor created', http_code=201) + + +@vendors_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_vendor(vendor_id: int): + """Update a vendor.""" + v = db.session.get(Vendor, vendor_id) + + if not v: + return error_response( + ErrorCodes.NOT_FOUND, + f'Vendor with ID {vendor_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'vendor' in data and data['vendor'] != v.vendor: + if Vendor.query.filter_by(vendor=data['vendor']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Vendor '{data['vendor']}' already exists", + http_code=409 + ) + + for key in ['vendor', 'description', 'website', 'supportphone', 'supportemail', 'notes', 'isactive']: + if key in data: + setattr(v, key, data[key]) + + apply_import_timestamps(v, data) + db.session.commit() + return success_response(v.to_dict(), message='Vendor updated') + + +@vendors_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_vendor(vendor_id: int): + """Delete (deactivate) a vendor.""" + v = db.session.get(Vendor, vendor_id) + + if not v: + return error_response( + ErrorCodes.NOT_FOUND, + f'Vendor with ID {vendor_id} not found', + http_code=404 + ) + + v.isactive = False + db.session.commit() + + return success_response(message='Vendor deleted') diff --git a/shopdb/core/models/__init__.py b/shopdb/core/models/__init__.py new file mode 100644 index 00000000..af6bddba --- /dev/null +++ b/shopdb/core/models/__init__.py @@ -0,0 +1,70 @@ +"""Core SQLAlchemy models.""" + +from .base import BaseModel, SoftDeleteMixin, AuditMixin +from .asset import Asset, AssetType, AssetStatus +from .modeltype import ModelType +from .vendor import Vendor +from .model import Model +from .businessunit import BusinessUnit +from .dashboarddefault import DashboardDefault +from .location import Location, LocationType, derive_locationcode +from .operatingsystem import OperatingSystem +from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation +from .communication import Communication, CommunicationType +from .user import User, Role, Permission, full_permission_catalog +from .application import Application, AppVersion +from .supportteam import SupportTeam, SupportTeamContact +from .setting import Setting +from .auditlog import AuditLog +from .customfield import CustomField, CustomFieldValue +from .apitoken import ApiToken + +__all__ = [ + # Base + 'BaseModel', + 'SoftDeleteMixin', + 'AuditMixin', + # Asset (new architecture) + 'Asset', + 'AssetType', + 'AssetStatus', + # Model-type lookup (referenced by models.modeltypeid) + 'ModelType', + # Reference + 'Vendor', + 'Model', + 'BusinessUnit', + 'DashboardDefault', + 'Location', + 'LocationType', + 'derive_locationcode', + 'OperatingSystem', + # Relationships + 'AssetRelationship', + 'RelationshipType', + 'RelationshipTypePropagation', + # Communication + 'Communication', + 'CommunicationType', + # Auth + 'User', + 'Role', + 'Permission', + 'full_permission_catalog', + # Applications + 'Application', + 'AppVersion', + # Support teams + 'SupportTeam', + 'SupportTeamContact', + # Knowledge Base + # Settings + 'Setting', + # Audit + 'AuditLog', + # Custom fields + 'CustomField', + 'CustomFieldValue', + # Personal API tokens + 'ApiToken', +] diff --git a/shopdb/core/models/apitoken.py b/shopdb/core/models/apitoken.py new file mode 100644 index 00000000..86ff5019 --- /dev/null +++ b/shopdb/core/models/apitoken.py @@ -0,0 +1,128 @@ +"""Personal API token model. + +A personal API token (PAT) lets a script or integration authenticate as a +user without the hourly-expiring login JWT. The secret is shown ONCE at +creation; only its sha256 hash is stored. By default the token acts as its +owning user, so the existing role/permission decorators authorize it unchanged. +A token MAY optionally carry a scopes list (see scopes/scopelist): a scoped +token grants only the listed permissions, intersected with what the owner holds, +and suspends the admin bypass. See shopdb/utils/authz.py for the enforcement. +""" + +import hashlib +import json +import secrets +from datetime import datetime, timezone + +from shopdb.extensions import db +from .base import BaseModel + + +def _utcnow(): + # naive UTC to match the other DB DateTime columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) + + +# Wire label on the full secret. Scripts send "Authorization: Bearer ". +TOKEN_SECRET_PREFIX = 'shopdb_pat_' +# Hex chars of randomness after the label (secrets.token_hex(20) => 40 hex). +_TOKEN_RANDOM_BYTES = 20 +# How many leading random-hex chars we keep in the clear for display/lookup. +_TOKEN_PREFIX_LEN = 8 + + +class ApiToken(BaseModel): + """Personal API token. Stores only the hash of the secret.""" + __tablename__ = 'apitokens' + + tokenid = db.Column(db.Integer, primary_key=True) + # The token acts as this user; NOT NULL so authz always has a principal. + userid = db.Column(db.Integer, db.ForeignKey('users.userid'), + nullable=False, index=True) + # What the token is for (e.g. "legacy import runner"). + name = db.Column(db.String(100), nullable=False) + # First few random-hex chars, kept clear so a user can tell tokens apart. + tokenprefix = db.Column(db.String(16), nullable=True, index=True) + # sha256 hex of the full secret. Unique so a hash lookup finds one row. + tokenhash = db.Column(db.String(64), unique=True, nullable=False, index=True) + # Null expiresat means the token never expires. + expiresat = db.Column(db.DateTime, nullable=True) + # Last time the token authenticated a request (throttled write). + lastusedat = db.Column(db.DateTime, nullable=True) + # JSON array of permission-name strings. NULL = unscoped (full owner + # authority). A scoped token grants ONLY these, intersected with what the + # owner holds, and suspends the admin bypass. See scopelist below. + scopes = db.Column(db.Text, nullable=True) + + user = db.relationship('User', backref=db.backref('apitokens', lazy='dynamic')) + + @staticmethod + def generate_secret() -> str: + """Return a fresh full secret: shopdb_pat_<40 hex>. Never stored.""" + return TOKEN_SECRET_PREFIX + secrets.token_hex(_TOKEN_RANDOM_BYTES) + + @staticmethod + def hash_secret(secret: str) -> str: + """sha256 hex of the full secret. The token has 160 bits of entropy, + so a plain hash lookup (not a slow password hash) is appropriate.""" + return hashlib.sha256(secret.encode('utf-8')).hexdigest() + + @staticmethod + def prefix_of(secret: str) -> str: + """The clear display prefix (leading random-hex chars) of a secret.""" + randompart = secret[len(TOKEN_SECRET_PREFIX):] + return randompart[:_TOKEN_PREFIX_LEN] + + @property + def is_expired(self) -> bool: + """True when expiresat is set and in the past.""" + return self.expiresat is not None and self.expiresat < _utcnow() + + @property + def scopelist(self): + """Parsed scope names as a list, or None when the token is unscoped.""" + if self.scopes is None: + return None + try: + value = json.loads(self.scopes) + except (ValueError, TypeError): + return None + return value if isinstance(value, list) else None + + @scopelist.setter + def scopelist(self, names): + """Store a scope list, or None to clear scoping. Does NOT validate the + names; callers reject bad input first via unknown_scope_names.""" + if names is None: + self.scopes = None + else: + self.scopes = json.dumps(list(names)) + + @staticmethod + def unknown_scope_names(names) -> list: + """Return the subset of names that are not in the permission catalog. + + The catalog is core plus every ENABLED plugin's permissions, so a scope + naming a disabled plugin's permission is treated as unknown.""" + from shopdb.core.models.user import full_permission_catalog + known = {name for name, _desc, _cat in full_permission_catalog()} + return [n for n in names if n not in known] + + def to_dict(self, include_owner: bool = False) -> dict: + """Serialize for the API. NEVER includes the hash or the secret.""" + result = { + 'tokenid': self.tokenid, + 'userid': self.userid, + 'name': self.name, + 'tokenprefix': self.tokenprefix, + 'displayprefix': f'{TOKEN_SECRET_PREFIX}{self.tokenprefix or ""}', + 'expiresat': self.expiresat.isoformat() + 'Z' if self.expiresat else None, + 'lastusedat': self.lastusedat.isoformat() + 'Z' if self.lastusedat else None, + 'scopes': self.scopelist, + 'isactive': self.isactive, + 'isexpired': self.is_expired, + 'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None, + } + if include_owner: + result['username'] = self.user.username if self.user else None + return result diff --git a/shopdb/core/models/application.py b/shopdb/core/models/application.py new file mode 100644 index 00000000..1ddf72df --- /dev/null +++ b/shopdb/core/models/application.py @@ -0,0 +1,95 @@ +"""Application tracking models.""" + +from datetime import datetime, timezone + +from shopdb.extensions import db +from .base import BaseModel + + +def _utcnow(): + # naive UTC to match the other DB DateTime columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) +# SupportTeam / SupportTeamContact live in supportteam.py; imported by the +# models package so the Application.supportteam relationship resolves. + + +class Application(BaseModel): + """Application catalog.""" + __tablename__ = 'applications' + + appid = db.Column(db.Integer, primary_key=True) + appname = db.Column(db.String(100), unique=True, nullable=False) + appdescription = db.Column(db.String(255)) + supportteamid = db.Column(db.Integer, db.ForeignKey('supportteams.supportteamid')) + isinstallable = db.Column(db.Boolean, default=False) + applicationnotes = db.Column(db.Text) + installpath = db.Column(db.String(255)) + applicationlink = db.Column(db.String(512)) + documentationpath = db.Column(db.String(512)) + ishidden = db.Column(db.Boolean, default=False) + isprinter = db.Column(db.Boolean, default=False) + islicenced = db.Column(db.Boolean, default=False) + isrequired = db.Column( + db.Boolean, default=False, + comment='Required on all PCs (drives the software-compliance report)' + ) + image = db.Column(db.String(255)) + + # Relationships + supportteam = db.relationship('SupportTeam', back_populates='applications') + versions = db.relationship('AppVersion', back_populates='application', lazy='dynamic') + + def to_dict(self): + """Serialize, flattening the support team + its active contacts. + + Emits supportteamname, teamurl, and the team's active contacts so the + frontend needs a single call to render the Support card. + """ + result = super().to_dict() + team = self.supportteam + result['supportteamname'] = team.teamname if team else None + result['teamurl'] = team.teamurl if team else None + result['contacts'] = [ + {'name': c.name, 'sso': c.sso} + for c in team.active_contacts() + ] if team else [] + return result + + def __repr__(self): + return f"" + + +class AppVersion(db.Model): + """Application version tracking.""" + __tablename__ = 'appversions' + + appversionid = db.Column(db.Integer, primary_key=True) + appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False) + version = db.Column(db.String(50), nullable=False) + releasedate = db.Column(db.Date) + notes = db.Column(db.String(255)) + dateadded = db.Column(db.DateTime, default=_utcnow) + isactive = db.Column(db.Boolean, default=True) + + # Relationships + application = db.relationship('Application', back_populates='versions') + + # Unique constraint on app + version + __table_args__ = ( + db.UniqueConstraint('appid', 'version', name='uq_app_version'), + ) + + def to_dict(self): + """Convert to dictionary.""" + return { + 'appversionid': self.appversionid, + 'appid': self.appid, + 'version': self.version, + 'releasedate': self.releasedate.isoformat() if self.releasedate else None, + 'notes': self.notes, + 'dateadded': self.dateadded.isoformat() + 'Z' if self.dateadded else None, + 'isactive': self.isactive + } + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/asset.py b/shopdb/core/models/asset.py new file mode 100644 index 00000000..5f0db55c --- /dev/null +++ b/shopdb/core/models/asset.py @@ -0,0 +1,285 @@ +"""Polymorphic Asset models - core of the new asset architecture.""" + +from shopdb.extensions import db +from .base import BaseModel, SoftDeleteMixin, AuditMixin + + +class AssetType(BaseModel): + """ + Registry of asset categories. + + Each type maps to a plugin-owned extension table. + Examples: machine, computer, network_device, printer + """ + __tablename__ = 'assettypes' + + assettypeid = db.Column(db.Integer, primary_key=True) + assettype = db.Column( + db.String(50), + unique=True, + nullable=False, + comment='Category name: machine, computer, network_device, printer' + ) + pluginname = db.Column( + db.String(100), + nullable=True, + comment='Plugin that owns this type' + ) + tablename = db.Column( + db.String(100), + nullable=True, + comment='Extension table name for this type' + ) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + color = db.Column(db.String(20), comment='CSS color for UI/map markers') + + def __repr__(self): + return f"" + + +class AssetStatus(BaseModel): + """Asset status options.""" + __tablename__ = 'assetstatuses' + + statusid = db.Column(db.Integer, primary_key=True) + status = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + color = db.Column(db.String(20), comment='CSS color for UI') + + def __repr__(self): + return f"" + + +class Asset(BaseModel, SoftDeleteMixin, AuditMixin): + """ + Core asset model - minimal shared fields. + + Category-specific data lives in plugin extension tables + (machines, computers, network_devices, printers). + The assetid matches original machineid for migration compatibility. + """ + __tablename__ = 'assets' + + assetid = db.Column(db.Integer, primary_key=True) + + # Identification + assetnumber = db.Column( + db.String(50), + unique=True, + nullable=False, + index=True, + comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)' + ) + name = db.Column( + db.String(100), + comment='Display name/alias' + ) + gaugelabreference = db.Column( + db.String(50), + index=True, + comment='Gauge lab asset reference (authoritative tag the gauge lab ' + 'assigns to machines); distinct from assetnumber' + ) + maintenancereference = db.Column( + db.String(50), + index=True, + comment='Maintenance system asset reference; distinct from assetnumber' + ) + serialnumber = db.Column( + db.String(100), + index=True, + comment='Hardware serial number' + ) + + # Classification + assettypeid = db.Column( + db.Integer, + db.ForeignKey('assettypes.assettypeid'), + nullable=False + ) + statusid = db.Column( + db.Integer, + db.ForeignKey('assetstatuses.statusid'), + default=1, + comment='In Use, Spare, Retired, etc.' + ) + + # Location and organization + locationid = db.Column( + db.Integer, + db.ForeignKey('locations.locationid'), + nullable=True + ) + businessunitid = db.Column( + db.Integer, + db.ForeignKey('businessunits.businessunitid'), + nullable=True + ) + + # Floor map position (ADR-001: asset-specific override; nullable) + mapx = db.Column(db.Integer, comment='X coordinate on floor map (ADR-001)') + mapy = db.Column(db.Integer, comment='Y coordinate on floor map (ADR-001)') + + # Notes + notes = db.Column(db.Text, nullable=True) + + # Relationships + assettype = db.relationship('AssetType', backref='assets') + status = db.relationship('AssetStatus', backref='assets') + location = db.relationship('Location', backref='assets') + businessunit = db.relationship('BusinessUnit', backref='assets') + + # Communications (one-to-many) - will be migrated to use assetid + communications = db.relationship( + 'Communication', + foreign_keys='Communication.assetid', + backref='asset', + cascade='all, delete-orphan', + lazy='dynamic' + ) + + # Indexes + __table_args__ = ( + db.Index('idx_asset_type_bu', 'assettypeid', 'businessunitid'), + db.Index('idx_asset_location', 'locationid'), + db.Index('idx_asset_active', 'isactive'), + db.Index('idx_asset_status', 'statusid'), + ) + + def __repr__(self): + return f"" + + @property + def display_name(self): + """Get display name (name if set, otherwise assetnumber).""" + return self.name or self.assetnumber + + @property + def primary_ip(self): + """Get primary IP address from communications.""" + comm = self.communications.filter_by( + isprimary=True, + comtypeid=1 # IP type + ).first() + if comm: + return comm.ipaddress + # Fall back to any IP + comm = self.communications.filter_by(comtypeid=1).first() + return comm.ipaddress if comm else None + + def get_inherited_location(self): + """ + Get location data from a related asset if this asset has none. + + Returns dict with locationid, location_name, mapx, mapy, and + inherited_from (assetnumber of source asset) if location was inherited. + Returns None if no location data available. + """ + if self.locationid is not None or (self.mapx is not None and self.mapy is not None): + return None + + related_assets = [] + + if hasattr(self, 'incoming_relationships'): + for rel in self.incoming_relationships: + if rel.sourceasset and rel.isactive: + related_assets.append(rel.sourceasset) + + if hasattr(self, 'outgoing_relationships'): + for rel in self.outgoing_relationships: + if rel.targetasset and rel.isactive: + related_assets.append(rel.targetasset) + + for related in related_assets: + if related.locationid is not None or (related.mapx is not None and related.mapy is not None): + return { + 'locationid': related.locationid, + 'locationname': related.location.locationname if related.location else None, + 'mapx': related.mapx, + 'mapy': related.mapy, + 'inheritedfrom': related.assetnumber + } + + return None + + def to_dict(self, include_type_data=False, include_inherited_location=True): + """ + Convert model to dictionary. + + Args: + include_type_data: If True, include category-specific data from extension table + include_inherited_location: If True, include location from related assets when missing + """ + result = super().to_dict() + + # Add related object names for convenience + if self.assettype: + result['assettypename'] = self.assettype.assettype + result['assettypecolor'] = getattr(self.assettype, 'color', None) + if self.status: + result['statusname'] = self.status.status + result['statuscolor'] = self.status.color + if self.location: + result['locationname'] = self.location.locationname + if self.businessunit: + result['businessunitname'] = self.businessunit.businessunit + + # Add plugin-specific ID for navigation purposes + if hasattr(self, 'machine') and self.machine: + result['pluginid'] = self.machine.machineid + elif hasattr(self, 'computer') and self.computer: + result['pluginid'] = self.computer.computerid + elif hasattr(self, 'network_device') and self.network_device: + result['pluginid'] = self.network_device.networkdeviceid + elif hasattr(self, 'printer') and self.printer: + result['pluginid'] = self.printer.printerid + elif hasattr(self, 'measuringtool') and self.measuringtool: + result['pluginid'] = self.measuringtool.measuringtoolid + + # Include inherited location if this asset has no location data + if include_inherited_location: + inherited = self.get_inherited_location() + if inherited: + result['inheritedlocation'] = inherited + # Also set the location fields if they're missing + if result.get('locationid') is None: + result['locationid'] = inherited['locationid'] + result['locationname'] = inherited['locationname'] + if result.get('mapx') is None: + result['mapx'] = inherited['mapx'] + if result.get('mapy') is None: + result['mapy'] = inherited['mapy'] + + # Operation/short code of the resolved location (own or inherited). + # Derived from the location name's leading token; labels can encode a + # tool's inspection operation instead of the tool. None when unplaced. + from .location import derive_locationcode + result['locationcode'] = derive_locationcode(result.get('locationname')) + + # Include extension data if requested + if include_type_data: + ext_data = self._get_extension_data() + if ext_data: + result['typedata'] = ext_data + + return result + + def _get_extension_data(self): + """Get category-specific data from extension table.""" + # Check for machine extension + if hasattr(self, 'machine') and self.machine: + return self.machine.to_dict() + # Check for computer extension + if hasattr(self, 'computer') and self.computer: + return self.computer.to_dict() + # Check for network_device extension + if hasattr(self, 'network_device') and self.network_device: + return self.network_device.to_dict() + # Check for printer extension + if hasattr(self, 'printer') and self.printer: + return self.printer.to_dict() + # Check for measuring-tool extension + if hasattr(self, 'measuringtool') and self.measuringtool: + return self.measuringtool.to_dict() + return None diff --git a/shopdb/core/models/auditlog.py b/shopdb/core/models/auditlog.py new file mode 100644 index 00000000..7e7896c7 --- /dev/null +++ b/shopdb/core/models/auditlog.py @@ -0,0 +1,160 @@ +"""Audit log model for tracking changes.""" + +from datetime import datetime, timezone +from shopdb.extensions import db + +def _utcnow(): + # naive UTC for DB columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class AuditLog(db.Model): + """ + Audit log for tracking user actions. + + Records who did what, when, from where, and what changed. + """ + __tablename__ = 'auditlogs' + + auditlogid = db.Column(db.Integer, primary_key=True) + + # Who + userid = db.Column(db.Integer, db.ForeignKey('users.userid'), nullable=True) + username = db.Column(db.String(100), nullable=True) # Denormalized for history + + # When + timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False, index=True) + + # Where (client info) + ipaddress = db.Column(db.String(45), nullable=True) # IPv6 max length + useragent = db.Column(db.String(255), nullable=True) + + # What + action = db.Column(db.String(20), nullable=False, index=True) # created, updated, deleted + entitytype = db.Column(db.String(50), nullable=False, index=True) # Asset, Printer, Setting, etc. + entityid = db.Column(db.Integer, nullable=True) # ID of the affected record + entityname = db.Column(db.String(255), nullable=True) # Human-readable identifier + + # Changes (stored as JSON text) + _changes = db.Column('changes', db.Text, nullable=True) # {"field": {"old": x, "new": y}, ...} + + @property + def changes(self): + """Get changes as dict.""" + if self._changes: + import json + try: + return json.loads(self._changes) + except (json.JSONDecodeError, TypeError): + return None + return None + + @changes.setter + def changes(self, value): + """Set changes from dict.""" + if value is not None: + import json + self._changes = json.dumps(value) + else: + self._changes = None + + # Additional context + details = db.Column(db.Text, nullable=True) # Optional description + + # Relationship + user = db.relationship('User', backref=db.backref('auditlogs', lazy='dynamic')) + + def to_dict(self): + return { + 'auditlogid': self.auditlogid, + 'userid': self.userid, + 'username': self.username, + 'timestamp': self.timestamp.isoformat() + 'Z' if self.timestamp else None, + 'ipaddress': self.ipaddress, + 'useragent': self.useragent, + 'action': self.action, + 'entitytype': self.entitytype, + 'entityid': self.entityid, + 'entityname': self.entityname, + 'changes': self.changes, + 'details': self.details + } + + @classmethod + def log(cls, action: str, entitytype: str, entityid: int = None, + entityname: str = None, changes: dict = None, details: str = None, + user=None, request=None): + """ + Create an audit log entry. + + Args: + action: 'created', 'updated', 'deleted' + entitytype: Type of entity (e.g., 'Asset', 'Printer', 'Setting') + entityid: ID of the affected record + entityname: Human-readable name/identifier + changes: Dict of field changes {"field": {"old": x, "new": y}} + details: Optional description + user: Current user object (or will try to get from flask-jwt-extended) + request: Flask request object (or will try to get current request) + """ + from flask import request as flask_request + from flask_jwt_extended import current_user, verify_jwt_in_request + + # Get user info + if user is None: + try: + verify_jwt_in_request(optional=True) + user = current_user + except: + pass + + userid = user.userid if user else None + username = user.username if user else None + + # Get request info + request_obj = request or flask_request + ipaddress = None + useragent = None + + if request_obj: + # Handle proxy forwarding + ipaddress = request_obj.headers.get('X-Forwarded-For', request_obj.remote_addr) + if ipaddress and ',' in ipaddress: + ipaddress = ipaddress.split(',')[0].strip() + useragent = request_obj.headers.get('User-Agent', '')[:255] + + entry = cls( + userid=userid, + username=username, + ipaddress=ipaddress, + useragent=useragent, + action=action, + entitytype=entitytype, + entityid=entityid, + entityname=entityname, + changes=changes, + details=details + ) + + db.session.add(entry) + # Don't commit here - let the caller handle transaction + return entry + + @classmethod + def log_create(cls, entitytype: str, entity, name_field: str = 'name'): + """Log a create action.""" + entityname = getattr(entity, name_field, None) or str(entity) + entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None) + return cls.log('created', entitytype, entityid=entityid, entityname=entityname) + + @classmethod + def log_update(cls, entitytype: str, entity, changes: dict, name_field: str = 'name'): + """Log an update action with changes.""" + entityname = getattr(entity, name_field, None) or str(entity) + entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None) + return cls.log('updated', entitytype, entityid=entityid, entityname=entityname, changes=changes) + + @classmethod + def log_delete(cls, entitytype: str, entityid: int, entityname: str = None): + """Log a delete action.""" + return cls.log('deleted', entitytype, entityid=entityid, entityname=entityname) diff --git a/shopdb/core/models/base.py b/shopdb/core/models/base.py new file mode 100644 index 00000000..9349418a --- /dev/null +++ b/shopdb/core/models/base.py @@ -0,0 +1,71 @@ +"""Base model class with common fields.""" + +from datetime import datetime, timezone +from shopdb.extensions import db + + +def _utcnow(): + # naive UTC for DB columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class BaseModel(db.Model): + """ + Abstract base model with common fields. + All models should inherit from this. + """ + __abstract__ = True + + createddate = db.Column( + db.DateTime, + default=_utcnow, + nullable=False + ) + modifieddate = db.Column( + db.DateTime, + default=_utcnow, + onupdate=_utcnow, + nullable=False + ) + isactive = db.Column(db.Boolean, default=True, nullable=False) + + def to_dict(self): + """Convert model to dictionary.""" + result = {} + for c in self.__table__.columns: + value = getattr(self, c.name) + if isinstance(value, datetime): + value = value.isoformat() + 'Z' + result[c.name] = value + return result + + def update(self, **kwargs): + """Update model attributes.""" + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + @classmethod + def get_active(cls): + """Return query for active records only.""" + return cls.query.filter_by(isactive=True) + + +class SoftDeleteMixin: + """Mixin for soft delete functionality.""" + + deleteddate = db.Column(db.DateTime, nullable=True) + deletedby = db.Column(db.String(100), nullable=True) + + def soft_delete(self, deleted_by: str = None): + """Mark record as deleted.""" + self.isactive = False + self.deleteddate = datetime.now(timezone.utc).replace(tzinfo=None) + self.deletedby = deleted_by + + +class AuditMixin: + """Mixin for audit fields.""" + + createdby = db.Column(db.String(100), nullable=True) + modifiedby = db.Column(db.String(100), nullable=True) diff --git a/shopdb/core/models/businessunit.py b/shopdb/core/models/businessunit.py new file mode 100644 index 00000000..3f97163c --- /dev/null +++ b/shopdb/core/models/businessunit.py @@ -0,0 +1,31 @@ +"""Business Unit model.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class BusinessUnit(BaseModel): + """Business unit / department model.""" + __tablename__ = 'businessunits' + + businessunitid = db.Column(db.Integer, primary_key=True) + businessunit = db.Column(db.String(100), unique=True, nullable=False) + code = db.Column(db.String(20), unique=True, comment='Short code') + description = db.Column(db.Text) + + # Optional parent for hierarchy + parentid = db.Column( + db.Integer, + db.ForeignKey('businessunits.businessunitid'), + nullable=True + ) + + # Self-referential relationship for hierarchy + parent = db.relationship( + 'BusinessUnit', + remote_side=[businessunitid], + backref='children' + ) + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/communication.py b/shopdb/core/models/communication.py new file mode 100644 index 00000000..5254d8b0 --- /dev/null +++ b/shopdb/core/models/communication.py @@ -0,0 +1,93 @@ +"""Communication/network interface models.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class CommunicationType(BaseModel): + """Types of communication interfaces.""" + __tablename__ = 'communicationtypes' + + comtypeid = db.Column(db.Integer, primary_key=True) + comtype = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + + # Types: IP, Serial, USB, VNC, FTP, DNC, Parallel, Network Interface + + def __repr__(self): + return f"" + + +class Communication(BaseModel): + """ + Communication interface for an asset (or legacy machine). + Stores network config, serial settings, etc. + """ + __tablename__ = 'communications' + + communicationid = db.Column(db.Integer, primary_key=True) + + # New asset-based FK (preferred) + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid'), + nullable=True, + comment='FK to assets table (new architecture)' + ) + + comtypeid = db.Column( + db.Integer, + db.ForeignKey('communicationtypes.comtypeid'), + nullable=False + ) + + # Network configuration (for IP type) + ipaddress = db.Column(db.String(50)) + subnetmask = db.Column(db.String(50)) + gateway = db.Column(db.String(50)) + dns1 = db.Column(db.String(50)) + dns2 = db.Column(db.String(50)) + macaddress = db.Column(db.String(50)) + isdhcp = db.Column(db.Boolean, default=False) + + # Serial configuration (for Serial type) + comport = db.Column(db.String(20)) + baudrate = db.Column(db.Integer) + databits = db.Column(db.Integer) + stopbits = db.Column(db.String(10)) + parity = db.Column(db.String(20)) + flowcontrol = db.Column(db.String(20)) + + # VNC/FTP configuration + port = db.Column(db.Integer) + username = db.Column(db.String(100)) + # Note: passwords should not be stored here - use secure vault + + # DNC configuration + pathname = db.Column(db.String(255)) + pathname2 = db.Column(db.String(255), comment='Secondary path for dualpath') + + # Flags + isprimary = db.Column( + db.Boolean, + default=False, + comment='Primary communication method' + ) + ismachinenetwork = db.Column( + db.Boolean, + default=False, + comment='On machine network vs office network' + ) + + notes = db.Column(db.Text) + + # Relationships + comtype = db.relationship('CommunicationType', backref='communications') + + __table_args__ = ( + db.Index('idx_comm_asset', 'assetid'), + db.Index('idx_comm_ip', 'ipaddress'), + ) + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/customfield.py b/shopdb/core/models/customfield.py new file mode 100644 index 00000000..afb39ac1 --- /dev/null +++ b/shopdb/core/models/customfield.py @@ -0,0 +1,94 @@ +"""Custom fields: site-defined extra attributes per asset type. + +A CustomField is a definition scoped to one asset type (machine, computer, +printer, network_device). A CustomFieldValue holds one asset's value for one +field. This is the generic form of the built-in identifier columns - sites add +their own attributes without a schema change. +""" + +from shopdb.extensions import db + +# Allowed datatypes for a custom field. Values are always stored as text and +# cast on render/input by the frontend. +CUSTOM_FIELD_DATATYPES = ('text', 'number', 'date', 'boolean', 'select') + + +class CustomField(db.Model): + __tablename__ = 'customfields' + + fieldid = db.Column(db.Integer, primary_key=True) + assettypeid = db.Column( + db.Integer, + db.ForeignKey('assettypes.assettypeid'), + nullable=False, + comment='Which asset category this field applies to' + ) + # Machine-name key, unique per asset type. Used for stable references. + fieldkey = db.Column(db.String(50), nullable=False) + label = db.Column(db.String(150), nullable=False) + datatype = db.Column(db.String(20), nullable=False, server_default='text') + # JSON array of options, only meaningful when datatype='select' + options = db.Column(db.Text) + showondetail = db.Column(db.Boolean, nullable=False, server_default='1') + showonform = db.Column(db.Boolean, nullable=False, server_default='1') + sortorder = db.Column(db.Integer, nullable=False, server_default='0') + isactive = db.Column(db.Boolean, nullable=False, server_default='1') + # When true, this field's values are matched by global search. + searchable = db.Column(db.Boolean, nullable=False, server_default='0') + + __table_args__ = ( + db.UniqueConstraint('assettypeid', 'fieldkey', name='uq_customfield_type_key'), + ) + + def to_dict(self): + import json + parsed_options = [] + if self.options: + try: + parsed_options = json.loads(self.options) + except (ValueError, TypeError): + parsed_options = [] + return { + 'fieldid': self.fieldid, + 'assettypeid': self.assettypeid, + 'fieldkey': self.fieldkey, + 'label': self.label, + 'datatype': self.datatype, + 'options': parsed_options, + 'showondetail': bool(self.showondetail), + 'showonform': bool(self.showonform), + 'sortorder': self.sortorder, + 'isactive': bool(self.isactive), + 'searchable': bool(self.searchable), + } + + +class CustomFieldValue(db.Model): + __tablename__ = 'customfieldvalues' + + valueid = db.Column(db.Integer, primary_key=True) + fieldid = db.Column( + db.Integer, + db.ForeignKey('customfields.fieldid', ondelete='CASCADE'), + nullable=False + ) + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + nullable=False + ) + value = db.Column(db.Text) + + field = db.relationship('CustomField') + + __table_args__ = ( + db.UniqueConstraint('fieldid', 'assetid', name='uq_customfieldvalue_field_asset'), + ) + + def to_dict(self): + return { + 'valueid': self.valueid, + 'fieldid': self.fieldid, + 'assetid': self.assetid, + 'value': self.value, + } diff --git a/shopdb/core/models/dashboarddefault.py b/shopdb/core/models/dashboarddefault.py new file mode 100644 index 00000000..951e41b5 --- /dev/null +++ b/shopdb/core/models/dashboarddefault.py @@ -0,0 +1,28 @@ +"""Dashboard default model: visitor-IP -> business-unit mapping. + +A shopfloor / lobby kiosk display resolves which business unit to show by the +display PC's IP address. Powers the visitor-location lookup the shopfloor and +TV dashboards call when no explicit business unit is given. +""" + +from shopdb.extensions import db +from .base import BaseModel + + +class DashboardDefault(BaseModel): + """Maps a display PC IP address to the business unit it should show.""" + __tablename__ = 'dashboarddefaults' + + dashboarddefaultid = db.Column(db.Integer, primary_key=True) + ipaddress = db.Column(db.String(50), unique=True, nullable=False) + businessunitid = db.Column( + db.Integer, + db.ForeignKey('businessunits.businessunitid'), + nullable=False + ) + description = db.Column(db.String(255)) + + businessunit = db.relationship('BusinessUnit') + + def __repr__(self): + return f" {self.businessunitid}>" diff --git a/shopdb/core/models/location.py b/shopdb/core/models/location.py new file mode 100644 index 00000000..1453b589 --- /dev/null +++ b/shopdb/core/models/location.py @@ -0,0 +1,91 @@ +"""Location + LocationType models.""" + +from shopdb.extensions import db +from .base import BaseModel + + +def derive_locationcode(locationname): + """Operation/short code for a location = leading token of its name. + + Locations have no dedicated code column, so the operation code is the + leading whitespace-delimited token of the location name. Example: + '0615 Blisk Inspection' -> '0615'. Blank/None name -> None. Used by + printed labels that encode a tool's inspection operation rather than the + tool itself. + """ + if not locationname: + return None + parts = str(locationname).strip().split() + return parts[0] if parts else None + + +class LocationType(BaseModel): + """Location classification (ADR-001 shared reference data). + + Seeded values: section, cell, subcell, operation, meetingroom, lab, + office, storage, hallway, networkcloset, building. Sites can extend. + """ + __tablename__ = 'locationtypes' + + locationtypeid = db.Column(db.Integer, primary_key=True) + locationtype = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + color = db.Column(db.String(20), comment='CSS color for UI') + + def __repr__(self): + return f"" + + +class Location(BaseModel): + """Physical location model.""" + __tablename__ = 'locations' + + locationid = db.Column(db.Integer, primary_key=True) + locationname = db.Column(db.String(100), unique=True, nullable=False) + building = db.Column(db.String(100)) + floor = db.Column(db.String(50)) + room = db.Column(db.String(50)) + description = db.Column(db.Text) + + # Classification + tree (ADR-001) + locationtypeid = db.Column( + db.Integer, + db.ForeignKey('locationtypes.locationtypeid'), + nullable=True + ) + parentlocationid = db.Column( + db.Integer, + db.ForeignKey('locations.locationid'), + nullable=True, + comment='Parent location for the site location tree' + ) + + # Map configuration + mapimage = db.Column(db.String(500), comment='Path to floor map image') + mapwidth = db.Column(db.Integer) + mapheight = db.Column(db.Integer) + + # Location-level position used as fallback when an asset has no own coords + # and no inheritsposition relationship resolves. ADR-001 position resolution + # chain priority 3. + mapx = db.Column(db.Integer, comment='Default X coordinate for assets at this location') + mapy = db.Column(db.Integer, comment='Default Y coordinate for assets at this location') + + # Relationships + locationtype = db.relationship('LocationType') + parent = db.relationship('Location', remote_side=[locationid]) + + @property + def locationcode(self): + """Derived operation/short code (leading token of the name).""" + return derive_locationcode(self.locationname) + + def to_dict(self): + data = super().to_dict() + data['locationtypename'] = self.locationtype.locationtype if self.locationtype else None + data['parentlocationname'] = self.parent.locationname if self.parent else None + data['locationcode'] = self.locationcode + return data + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/model.py b/shopdb/core/models/model.py new file mode 100644 index 00000000..130eb7c4 --- /dev/null +++ b/shopdb/core/models/model.py @@ -0,0 +1,43 @@ +"""Model (vendor catalog model number) model.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class Model(BaseModel): + """Vendor catalog model information (machines, PCs, printers, network).""" + __tablename__ = 'models' + + modelnumberid = db.Column(db.Integer, primary_key=True) + modelnumber = db.Column(db.String(100), nullable=False) + + # Link to model type (what kind of thing this catalog model is for) + modeltypeid = db.Column( + db.Integer, + db.ForeignKey('modeltypes.modeltypeid'), + nullable=True + ) + + # Link to vendor/manufacturer + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + + description = db.Column(db.Text) + imageurl = db.Column(db.String(500), comment='URL to product image') + documentationurl = db.Column(db.String(500), comment='URL to documentation') + notes = db.Column(db.Text) + + # Relationships + modeltype = db.relationship('ModelType', backref='models') + vendor = db.relationship('Vendor', backref='models') + + # Unique constraint on modelnumber + vendor + __table_args__ = ( + db.UniqueConstraint('modelnumber', 'vendorid', name='uq_model_vendor'), + ) + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/modeltype.py b/shopdb/core/models/modeltype.py new file mode 100644 index 00000000..51ecad35 --- /dev/null +++ b/shopdb/core/models/modeltype.py @@ -0,0 +1,33 @@ +"""Model-type lookup (types the vendor MODELS catalog). + +The Machine instance model and its PC/status lookups were retired (ADR-001); +assets are the platform contract. ModelType is kept because the shared `models` +table references it via models.modeltypeid: it types vendor models (Lathe, +Switch, Laser Printer), not asset instances. Renamed from MachineType to be +role-accurate and to free the "machinetype" name for the machines plugin. +""" + +from shopdb.extensions import db +from .base import BaseModel + + +class ModelType(BaseModel): + """ + Model-type classification (what kind of thing a catalog model is for). + Categories: Equipment, PC, Network, Printer + """ + __tablename__ = 'modeltypes' + + modeltypeid = db.Column(db.Integer, primary_key=True) + modeltype = db.Column(db.String(100), unique=True, nullable=False) + category = db.Column( + db.String(50), + nullable=False, + default='Equipment', + comment='Equipment, PC, Network, or Printer' + ) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/operatingsystem.py b/shopdb/core/models/operatingsystem.py new file mode 100644 index 00000000..7bb851f1 --- /dev/null +++ b/shopdb/core/models/operatingsystem.py @@ -0,0 +1,22 @@ +"""Operating System model.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class OperatingSystem(BaseModel): + """Operating system model.""" + __tablename__ = 'operatingsystems' + + osid = db.Column(db.Integer, primary_key=True) + osname = db.Column(db.String(100), nullable=False) + osversion = db.Column(db.String(50)) + architecture = db.Column(db.String(20), comment='x86, x64, ARM') + endoflife = db.Column(db.Date, comment='End of support date') + + __table_args__ = ( + db.UniqueConstraint('osname', 'osversion', name='uq_os_name_version'), + ) + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/relationship.py b/shopdb/core/models/relationship.py new file mode 100644 index 00000000..9e81e6ac --- /dev/null +++ b/shopdb/core/models/relationship.py @@ -0,0 +1,166 @@ +"""Machine and Asset relationship models.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class RelationshipType(BaseModel): + """ + Types of relationships between assets. + + ADR-001 seeds three canonical types: partof, controls, connectedto. + Sites may add legacy/communication-flavored types (Serial Cable, Direct + Ethernet, USB, WiFi, Dualpath) for backward compatibility with pre-1.0 + data, but new ADR-001 code paths only reason about the three canonical + types via free-text label for nuance. + """ + __tablename__ = 'relationshiptypes' + + relationshiptypeid = db.Column(db.Integer, primary_key=True) + relationshiptype = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + color = db.Column(db.String(20), comment='CSS color for relationship badges') + + # True: edge has a source->target meaning (controls, partof, Backup For). + # False: symmetric link (Dualpath, connectedto, USB...) shown on the card + # once per peer with no direction, both stored direction rows collapsed. + isdirectional = db.Column( + db.Boolean, + default=True, + nullable=False, + server_default='1', + comment='False = symmetric connection, shown direction-blind' + ) + + # Sibling propagation (ADR-001), WIRED. When a relationship of this type is + # created, the create path fans it out across every through-type in this + # type's propagation set (relationshiptypepropagations rows). For each + # SYMMETRIC through-type P, the source's edge to the target is mirrored to + # every asset linked to the target via P. This is M:N so one type can + # propagate through several rails at once. Seeded rows on this DB: + # controls -> partof (declared; directional rail, NOT consumed yet - + # parent/child fan-out direction is ambiguous) + # controls -> Dualpath (consumed; a dual-bay pair is one physical machine + # with one controller, so both bays carry controls) + # A dual-bay Dualpath pair thus gets the PC's controls link on both bays. + # propagatesthrough is the list of through-type RelationshipType rows. + propagatesthrough = db.relationship( + 'RelationshipType', + secondary='relationshiptypepropagations', + primaryjoin='RelationshipType.relationshiptypeid' + ' == RelationshipTypePropagation.relationshiptypeid', + secondaryjoin='RelationshipType.relationshiptypeid' + ' == RelationshipTypePropagation.throughtypeid', + viewonly=True, + ) + + def __repr__(self): + return f"" + + +class RelationshipTypePropagation(db.Model): + """ + M:N propagation rails (ADR-001). One row means: a relationship of type + relationshiptypeid propagates through the connections of type throughtypeid. + + Replaces the old single-valued relationshiptypes.propagatesthroughid so a + type (controls) can propagate through several through-types (partof AND + Dualpath) at once. Seed/CLI-managed; no management UI. + """ + __tablename__ = 'relationshiptypepropagations' + + propagationid = db.Column(db.Integer, primary_key=True) + relationshiptypeid = db.Column( + db.Integer, + db.ForeignKey('relationshiptypes.relationshiptypeid'), + nullable=False + ) + throughtypeid = db.Column( + db.Integer, + db.ForeignKey('relationshiptypes.relationshiptypeid'), + nullable=False + ) + + __table_args__ = ( + db.UniqueConstraint( + 'relationshiptypeid', + 'throughtypeid', + name='uq_reltype_propagation' + ), + ) + + def __repr__(self): + return f"" + + +class AssetRelationship(BaseModel): + """ + Relationships between assets. + + Examples: + - Computer controls Machine + - Two machines are dualpath partners + - Network device connects to machine + """ + __tablename__ = 'assetrelationships' + + relationshipid = db.Column(db.Integer, primary_key=True) + + sourceassetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid'), + nullable=False + ) + targetassetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid'), + nullable=False + ) + relationshiptypeid = db.Column( + db.Integer, + db.ForeignKey('relationshiptypes.relationshiptypeid'), + nullable=False + ) + + # Free-text description carrying domain nuance ("DNC feed", + # "operator workstation", "ethernet PoE"). Avoids inflating type list. + label = db.Column(db.String(200), comment='Free-text relationship description (ADR-001)') + + # When true, resolve_asset_position walks across this edge (priority 2 + # in the resolution chain). Defaults to true for partof + controls when + # the relationship is created via the API; nullable for legacy rows. + inheritsposition = db.Column( + db.Boolean, + default=True, + nullable=False, + server_default='1', + comment='If true, resolved-position walk follows this edge (ADR-001)' + ) + + notes = db.Column(db.Text) + + sourceasset = db.relationship( + 'Asset', + foreign_keys=[sourceassetid], + backref='outgoing_relationships' + ) + targetasset = db.relationship( + 'Asset', + foreign_keys=[targetassetid], + backref='incoming_relationships' + ) + relationshiptype = db.relationship('RelationshipType', backref='asset_relationships') + + __table_args__ = ( + db.UniqueConstraint( + 'sourceassetid', + 'targetassetid', + 'relationshiptypeid', + name='uq_asset_relationship' + ), + db.Index('idx_asset_rel_source', 'sourceassetid'), + db.Index('idx_asset_rel_target', 'targetassetid'), + ) + + def __repr__(self): + return f" {self.targetassetid}>" diff --git a/shopdb/core/models/setting.py b/shopdb/core/models/setting.py new file mode 100644 index 00000000..8c82c1fa --- /dev/null +++ b/shopdb/core/models/setting.py @@ -0,0 +1,102 @@ +"""System settings model for key-value configuration storage.""" + +from datetime import datetime, timezone + +from sqlalchemy.exc import IntegrityError + +from shopdb.extensions import db + + +def _utcnow(): + """Naive UTC now for column defaults (matches the app's naive datetime cols).""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class Setting(db.Model): + """ + Key-value store for system settings. + + Settings can be managed via the admin UI and are cached + for performance. + """ + __tablename__ = 'settings' + + settingid = db.Column(db.Integer, primary_key=True, autoincrement=True) + key = db.Column(db.String(100), unique=True, nullable=False, index=True) + value = db.Column(db.Text, nullable=True) + valuetype = db.Column(db.String(20), default='string') # string, boolean, integer, json + category = db.Column(db.String(50), default='general') # For grouping in UI + description = db.Column(db.String(255), nullable=True) + createddate = db.Column(db.DateTime, default=_utcnow) + modifieddate = db.Column(db.DateTime, default=_utcnow, onupdate=_utcnow) + + def to_dict(self): + return { + 'settingid': self.settingid, + 'key': self.key, + 'value': self.get_typed_value(), + 'valuetype': self.valuetype, + 'category': self.category, + 'description': self.description, + 'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None, + 'modifieddate': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None, + } + + def get_typed_value(self): + """Return value converted to its proper type.""" + if self.value is None: + return None + if self.valuetype == 'boolean': + return self.value.lower() in ('true', '1', 'yes') + if self.valuetype == 'integer': + try: + return int(self.value) + except (ValueError, TypeError): + return 0 + return self.value + + @classmethod + def get(cls, key: str, default=None): + """Get a setting value by key.""" + setting = cls.query.filter_by(key=key).first() + if setting: + return setting.get_typed_value() + return default + + @staticmethod + def _stringify(value): + """Convert a value to its stored string form.""" + if isinstance(value, bool): + return 'true' if value else 'false' + return str(value) if value is not None else None + + @classmethod + def set(cls, key: str, value, valuetype: str = 'string', category: str = 'general', description: str = None): + """Set a setting value, creating if it doesn't exist. + + Handles the create race: two concurrent callers can both find no row and + both try to INSERT the same unique key. The loser's commit raises + IntegrityError; we roll back, re-fetch the row the winner created, and + apply our value to it. + """ + setting = cls.query.filter_by(key=key).first() + if setting: + setting.value = cls._stringify(value) + db.session.commit() + return setting + + setting = cls(key=key, valuetype=valuetype, category=category, + description=description, value=cls._stringify(value)) + db.session.add(setting) + try: + db.session.commit() + return setting + except IntegrityError: + db.session.rollback() + # Another transaction inserted this key first; update that row. + setting = cls.query.filter_by(key=key).first() + if setting is None: + raise + setting.value = cls._stringify(value) + db.session.commit() + return setting diff --git a/shopdb/core/models/supportteam.py b/shopdb/core/models/supportteam.py new file mode 100644 index 00000000..2730a578 --- /dev/null +++ b/shopdb/core/models/supportteam.py @@ -0,0 +1,61 @@ +"""Application support teams and their contacts. + +A support team is who you contact about an application; each team carries an +optional ServiceNow group deep link (teamurl) and a list of named contacts +(the people you actually reach out to, legacy called them "app owners"). +Applications point at one team via applications.supportteamid. +""" + +from shopdb.extensions import db +from .base import BaseModel + + +class SupportTeam(BaseModel): + """A support team an application belongs to.""" + __tablename__ = 'supportteams' + + supportteamid = db.Column(db.Integer, primary_key=True) + teamname = db.Column(db.String(100), unique=True, nullable=False) + teamurl = db.Column(db.Text) # ServiceNow group deep link, nullable + + # Contacts cascade-delete with the team. + contacts = db.relationship( + 'SupportTeamContact', back_populates='team', + cascade='all, delete-orphan', lazy='select') + applications = db.relationship( + 'Application', back_populates='supportteam', lazy='dynamic') + + def active_contacts(self): + """Return active contacts in sortorder (then contactid) order.""" + return sorted( + (c for c in self.contacts if c.isactive), + key=lambda c: (c.sortorder, c.contactid or 0)) + + def to_dict(self, with_contacts=True): + """Serialize the team, nesting its active contacts by default.""" + result = super().to_dict() + if with_contacts: + result['contacts'] = [c.to_dict() for c in self.active_contacts()] + return result + + def __repr__(self): + return f"" + + +class SupportTeamContact(BaseModel): + """A person to contact for a support team.""" + __tablename__ = 'supportteamcontacts' + + contactid = db.Column(db.Integer, primary_key=True) + supportteamid = db.Column( + db.Integer, + db.ForeignKey('supportteams.supportteamid', ondelete='CASCADE'), + nullable=False) + name = db.Column(db.String(100), nullable=False) + sso = db.Column(db.String(50)) + sortorder = db.Column(db.Integer, nullable=False, default=0) + + team = db.relationship('SupportTeam', back_populates='contacts') + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/user.py b/shopdb/core/models/user.py new file mode 100644 index 00000000..f672c16d --- /dev/null +++ b/shopdb/core/models/user.py @@ -0,0 +1,241 @@ +"""User and authentication models.""" + +from datetime import datetime, timezone +from shopdb.extensions import db +from .base import BaseModel + + +# Association table for user roles (many-to-many) +userroles = db.Table( + 'userroles', + db.Column('userid', db.Integer, db.ForeignKey('users.userid'), primary_key=True), + db.Column('roleid', db.Integer, db.ForeignKey('roles.roleid'), primary_key=True) +) + +# Association table for role permissions (many-to-many) +rolepermissions = db.Table( + 'rolepermissions', + db.Column('roleid', db.Integer, db.ForeignKey('roles.roleid'), primary_key=True), + db.Column('permissionid', db.Integer, db.ForeignKey('permissions.permissionid'), primary_key=True) +) + + +class Permission(db.Model): + """ + Permission model for granular access control. + + Permissions are predefined and assigned to roles. + """ + __tablename__ = 'permissions' + + permissionid = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.String(255)) + category = db.Column(db.String(50), default='general') # For grouping in UI + + # Core permission catalog. This is CORE ONLY - permissions for a plugin + # domain (machines, computers, printers, network, knowledgebase, + # notifications, usb, warranty, measuringtools) live in that plugin's + # BasePlugin.get_permissions() hook, not here (plugin-is-the-product). The + # merged core+plugins view is full_permission_catalog() below. + CORE_PERMISSIONS = [ + # Assets + ('assets.view', 'View assets', 'assets'), + ('assets.create', 'Create assets', 'assets'), + ('assets.edit', 'Edit assets', 'assets'), + ('assets.delete', 'Delete assets', 'assets'), + # Applications + ('applications.view', 'View applications', 'applications'), + ('applications.create', 'Create applications', 'applications'), + ('applications.edit', 'Edit applications', 'applications'), + ('applications.delete', 'Delete applications', 'applications'), + # Reports + ('reports.view', 'View reports', 'reports'), + ('reports.export', 'Export reports', 'reports'), + # Settings + ('settings.view', 'View settings', 'admin'), + ('settings.edit', 'Edit settings', 'admin'), + # Users + ('users.view', 'View users', 'admin'), + ('users.create', 'Create users', 'admin'), + ('users.edit', 'Edit users', 'admin'), + ('users.delete', 'Delete users', 'admin'), + # Audit + ('audit.view', 'View audit logs', 'admin'), + # API tokens + ('apitokens.create', 'Create and manage API tokens', 'apitokens'), + # Collector service tokens. A token scoped to ONLY this permission is a + # collector service token: it authorizes the collector ingest API and + # nothing else (scoped tokens pass require_permission only for listed + # perms, and this perm gates no other route). See collector.py. + ('collector.ingest', 'Submit collector payloads (fleet reporting)', + 'collector'), + ] + + def __repr__(self): + return f"" + + @classmethod + def seed_entries(cls, entries): + """Idempotently create Permission rows from (name, desc, category) + tuples (or equivalent dicts). Returns how many were created.""" + created = 0 + for name, description, category in normalize_permission_entries(entries): + if not cls.query.filter_by(name=name).first(): + perm = cls(name=name, description=description, category=category) + db.session.add(perm) + created += 1 + return created + + @classmethod + def seed(cls): + """Seed the full permission catalog (core plus enabled plugins).""" + return cls.seed_entries(full_permission_catalog()) + + +def normalize_permission_entries(entries): + """Coerce catalog entries to (name, description, category) tuples. + + Accepts tuples/lists in that order, or dicts with those keys, so a plugin + may return either shape from get_permissions(). + """ + normalized = [] + for entry in entries or []: + if isinstance(entry, dict): + normalized.append(( + entry['name'], + entry.get('description', ''), + entry.get('category', 'general'), + )) + else: + name, description, category = entry + normalized.append((name, description, category)) + return normalized + + +def full_permission_catalog(include_disabled=False): + """The full permission catalog: core permissions plus every LOADED plugin's + get_permissions(). + + By default only ENABLED plugins contribute, so a disabled plugin's + permissions are not offered for new scope grants or role edits (existing + Permission rows and role links persist - see the get_permissions docstring). + Pass include_disabled=True to include all installed plugins. + + Follows the dashboard.py consumer pattern: a broken plugin is re-raised in + dev/test and isolated (logged) in prod. Returns (name, description, + category) tuples. + """ + from flask import current_app, has_app_context + + catalog = list(Permission.CORE_PERMISSIONS) + if not has_app_context(): + return catalog + pm = current_app.extensions.get('plugin_manager') + if not pm: + return catalog + + for name, plugin in pm.get_all_plugins().items(): + if not include_disabled and not pm.registry.is_enabled(name): + continue + try: + catalog.extend( + normalize_permission_entries(plugin.get_permissions())) + except Exception: + if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): + raise + current_app.logger.exception( + 'Plugin %s get_permissions failed', name) + return catalog + + +class Role(BaseModel): + """User role model.""" + __tablename__ = 'roles' + + roleid = db.Column(db.Integer, primary_key=True) + rolename = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + + # Permissions relationship + permissions = db.relationship( + 'Permission', + secondary=rolepermissions, + backref=db.backref('roles', lazy='dynamic') + ) + + def __repr__(self): + return f"" + + def haspermission(self, permission_name: str) -> bool: + """Check if role has a specific permission.""" + # Admin role has all permissions + if self.rolename == 'admin': + return True + return any(p.name == permission_name for p in self.permissions) + + def getpermissionnames(self) -> list: + """Get list of permission names.""" + if self.rolename == 'admin': + return [p[0] for p in full_permission_catalog()] + return [p.name for p in self.permissions] + + +class User(BaseModel): + """User model for authentication.""" + __tablename__ = 'users' + + userid = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(100), unique=True, nullable=False, index=True) + email = db.Column(db.String(255), unique=True, nullable=False) + passwordhash = db.Column(db.String(255), nullable=False) + + # Profile + firstname = db.Column(db.String(100)) + lastname = db.Column(db.String(100)) + + # Status + lastlogindate = db.Column(db.DateTime) + failedlogins = db.Column(db.Integer, default=0) + lockeduntil = db.Column(db.DateTime) + # Forced password change: set true when an admin creates the account, so the + # user is steered through a password change before landing in the app. + mustchangepassword = db.Column(db.Boolean, default=False, nullable=False) + + # Relationships + roles = db.relationship( + 'Role', + secondary=userroles, + backref=db.backref('users', lazy='dynamic') + ) + + def __repr__(self): + return f"" + + @property + def islocked(self): + """Check if account is locked.""" + if self.lockeduntil: + return datetime.now(timezone.utc).replace(tzinfo=None) < self.lockeduntil + return False + + def hasrole(self, rolename: str) -> bool: + """Check if user has a specific role.""" + return any(r.rolename == rolename for r in self.roles) + + def haspermission(self, permission_name: str) -> bool: + """Check if user has a specific permission through any role.""" + # Admin role has all permissions + if self.hasrole('admin'): + return True + return any(r.haspermission(permission_name) for r in self.roles) + + def getpermissions(self) -> list: + """Get list of all permission names from all roles.""" + if self.hasrole('admin'): + return [p[0] for p in full_permission_catalog()] + + perms = set() + for role in self.roles: + perms.update(role.getpermissionnames()) + return list(perms) diff --git a/shopdb/core/models/vendor.py b/shopdb/core/models/vendor.py new file mode 100644 index 00000000..46532888 --- /dev/null +++ b/shopdb/core/models/vendor.py @@ -0,0 +1,20 @@ +"""Vendor model.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class Vendor(BaseModel): + """Vendor/Manufacturer model.""" + __tablename__ = 'vendors' + + vendorid = db.Column(db.Integer, primary_key=True) + vendor = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + website = db.Column(db.String(255)) + supportphone = db.Column(db.String(50)) + supportemail = db.Column(db.String(100)) + notes = db.Column(db.Text) + + def __repr__(self): + return f"" diff --git a/shopdb/core/schemas/__init__.py b/shopdb/core/schemas/__init__.py new file mode 100644 index 00000000..37609517 --- /dev/null +++ b/shopdb/core/schemas/__init__.py @@ -0,0 +1 @@ +"""Core Marshmallow schemas.""" diff --git a/shopdb/core/services/__init__.py b/shopdb/core/services/__init__.py new file mode 100644 index 00000000..0791aaa4 --- /dev/null +++ b/shopdb/core/services/__init__.py @@ -0,0 +1 @@ +"""Core services.""" diff --git a/shopdb/core/services/dualpath.py b/shopdb/core/services/dualpath.py new file mode 100644 index 00000000..d10a6dc6 --- /dev/null +++ b/shopdb/core/services/dualpath.py @@ -0,0 +1,115 @@ +"""Dualpath single-machine collapse resolution. + +A Dualpath relationship pair is ONE physical dual-bay machine (single +controller, one bay-selector switch) recorded as two asset rows. Most +facilities want lists, counts, and the map to show that pair as a single +machine; the data model always keeps both rows. This module resolves the +pairs so the consumers (machines list, dashboard/report counts, floor map) +can collapse them, and is exposed via the plugin contract surface +(shopdb.api) so the machines plugin can reach it contract-purely. + +PRIMARY = the pair member with the lower natural-sort assetnumber; the other +member is SECONDARY and is the one hidden when collapsing. Pairs are derived +from active assetrelationships rows whose type is named 'Dualpath' (either +direction; mirrored rows in both directions collapse to one pair). Only pairs +where BOTH assets are active are resolved. +""" + +import re +from collections import namedtuple + +from shopdb.core.models import Asset, AssetRelationship, RelationshipType + +# secondaryassetids: set of the non-primary bay asset ids (hide when collapsing) +# partnerbyasset: {assetid -> {'assetid', 'assetnumber'}} for EVERY pair member, +# primary and secondary alike, so detail pages can show a sibling +# banner from whichever bay you land on. +DualpathCollapse = namedtuple('DualpathCollapse', ['secondaryassetids', 'partnerbyasset']) + +DUALPATH_TYPE_NAME = 'Dualpath' + + +def _naturalkey(assetnumber): + # split into digit/non-digit chunks so 2007 sorts before 2008 and before 10a + parts = re.split(r'(\d+)', assetnumber or '') + return [int(p) if p.isdigit() else p.lower() for p in parts] + + +def resolve_dualpath_pairs(): + """Resolve active Dualpath pairs into a DualpathCollapse. + + Direction-blind and dedup-safe: rows stored in either direction (or both) + for the same two assets collapse to one pair. Ignores the site toggle; + callers gate on dualpath_single_machine_enabled() where the collapse should + only apply when the setting is on (the detail-page banner shows always). + """ + reltype = RelationshipType.query.filter_by( + relationshiptype=DUALPATH_TYPE_NAME).first() + if not reltype: + return DualpathCollapse(set(), {}) + + rows = AssetRelationship.query.filter( + AssetRelationship.relationshiptypeid == reltype.relationshiptypeid, + AssetRelationship.isactive == True, + ).all() + if not rows: + return DualpathCollapse(set(), {}) + + # batch-load the involved assets (assetnumber + active flag) in one query + involved = set() + for row in rows: + involved.add(row.sourceassetid) + involved.add(row.targetassetid) + assetbyid = { + a.assetid: a + for a in Asset.query.filter(Asset.assetid.in_(involved)).all() + } + + secondaryassetids = set() + partnerbyasset = {} + seenpairs = set() + for row in rows: + aid, bid = row.sourceassetid, row.targetassetid + if aid == bid: + continue # defensive: no self-pairs + pairkey = frozenset((aid, bid)) + if pairkey in seenpairs: + continue # mirrored row already handled + seenpairs.add(pairkey) + + aone = assetbyid.get(aid) + atwo = assetbyid.get(bid) + # collapse only affects visible/counted (active) assets + if not aone or not atwo or not aone.isactive or not atwo.isactive: + continue + + # PRIMARY = lower natural-sort assetnumber; SECONDARY is the other bay + if _naturalkey(aone.assetnumber) <= _naturalkey(atwo.assetnumber): + primary, secondary = aone, atwo + else: + primary, secondary = atwo, aone + + secondaryassetids.add(secondary.assetid) + partnerbyasset[primary.assetid] = { + 'assetid': secondary.assetid, + 'assetnumber': secondary.assetnumber, + } + partnerbyasset[secondary.assetid] = { + 'assetid': primary.assetid, + 'assetnumber': primary.assetnumber, + } + + return DualpathCollapse(secondaryassetids, partnerbyasset) + + +def dualpath_single_machine_enabled(): + """True when the site treats Dualpath pairs as one machine (default true). + + Reads the cached settings; the row is absent on an un-seeded site, in which + case the default (true - 'most places' consider a dual-bay pair one machine) + applies. + """ + # imported here to avoid a settings<->api import cycle at module load + from shopdb.core.api.settings import get_cached_settings + settings = get_cached_settings() + return bool(settings.get('dualpath_single_machine', True)) diff --git a/shopdb/exceptions.py b/shopdb/exceptions.py new file mode 100644 index 00000000..4c183b90 --- /dev/null +++ b/shopdb/exceptions.py @@ -0,0 +1,70 @@ +"""Custom exceptions for ShopDB.""" + + +class ShopDBException(Exception): + """Base exception for ShopDB.""" + + def __init__(self, message: str, code: str = None, details: dict = None): + super().__init__(message) + self.message = message + self.code = code or 'SHOPDB_ERROR' + self.details = details or {} + + +class ValidationError(ShopDBException): + """Validation error.""" + + def __init__(self, message: str, details: dict = None): + super().__init__(message, 'VALIDATION_ERROR', details) + + +class NotFoundError(ShopDBException): + """Resource not found error.""" + + def __init__(self, resource: str, identifier): + message = f"{resource} with ID {identifier} not found" + super().__init__(message, 'NOT_FOUND', {'resource': resource, 'id': identifier}) + + +class AuthenticationError(ShopDBException): + """Authentication error.""" + + def __init__(self, message: str = "Authentication required"): + super().__init__(message, 'UNAUTHORIZED') + + +class AuthorizationError(ShopDBException): + """Authorization error.""" + + def __init__(self, message: str = "Permission denied"): + super().__init__(message, 'FORBIDDEN') + + +class ConflictError(ShopDBException): + """Conflict error (e.g., duplicate entry).""" + + def __init__(self, message: str, details: dict = None): + super().__init__(message, 'CONFLICT', details) + + +class PluginError(ShopDBException): + """Plugin-related error.""" + + def __init__(self, message: str, plugin_name: str = None): + super().__init__(message, 'PLUGIN_ERROR', {'plugin': plugin_name}) + + +class PluginNotFoundError(PluginError): + """A plugin directory or manifest is missing.""" + + +class PluginContractError(PluginError): + """A plugin violates the BasePlugin contract (no subclass, missing hooks).""" + + +class PluginVersionError(PluginError): + """A plugin's core_version range does not include the framework's __contract_version__.""" + + +class PluginDependencyError(PluginError): + """A plugin declares a dependency on another plugin that is missing or not enabled.""" diff --git a/shopdb/extensions.py b/shopdb/extensions.py new file mode 100644 index 00000000..847a03b6 --- /dev/null +++ b/shopdb/extensions.py @@ -0,0 +1,28 @@ +"""Flask extensions initialization.""" + +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_jwt_extended import JWTManager +from flask_cors import CORS +from flask_marshmallow import Marshmallow +from flask_caching import Cache + +# Initialize extensions without app +db = SQLAlchemy() +migrate = Migrate() +jwt = JWTManager() +cors = CORS() +ma = Marshmallow() +cache = Cache() + + +def init_extensions(app): + """Initialize all Flask extensions with app.""" + db.init_app(app) + migrate.init_app(app, db) + jwt.init_app(app) + cors.init_app(app, resources={ + r"/api/*": {"origins": app.config.get('CORS_ORIGINS', '*')} + }) + ma.init_app(app) + cache.init_app(app) diff --git a/shopdb/plugins/__init__.py b/shopdb/plugins/__init__.py new file mode 100644 index 00000000..1729ab2d --- /dev/null +++ b/shopdb/plugins/__init__.py @@ -0,0 +1,399 @@ +"""Plugin manager - main entry point for plugin system.""" + +from pathlib import Path +from typing import Dict, List, Optional +from flask import Flask +import logging + +from .base import BasePlugin, PluginMeta +from .registry import PluginRegistry, PluginState +from .loader import PluginLoader +from .migrations import PluginMigrationManager + +logger = logging.getLogger(__name__) + +__all__ = [ + 'PluginManager', + 'BasePlugin', + 'PluginMeta', + 'PluginRegistry', + 'PluginState', + 'plugin_manager' +] + + +class PluginManager: + """ + Central manager for all plugin operations. + + Usage: + plugin_manager = PluginManager() + plugin_manager.init_app(app, db) + + # In CLI: + plugin_manager.install_plugin('printers') + """ + + def __init__(self): + self.registry: Optional[PluginRegistry] = None + self.loader: Optional[PluginLoader] = None + self.migration_manager: Optional[PluginMigrationManager] = None + self._app: Optional[Flask] = None + self._db = None + # API prefixes already claimed by a registered plugin blueprint, to + # detect two plugins overlapping on the same /api/... namespace. + self._registered_prefixes: set = set() + + def init_app(self, app: Flask, db) -> None: + """Initialize plugin manager with Flask app.""" + self._app = app + self._db = db + # Reset per-app so the prefix-uniqueness guard tracks only this app's + # registrations (the manager is a process-wide singleton; tests build + # multiple apps from it). + self._registered_prefixes = set() + + # Setup paths + instance_path = Path(app.instance_path) + plugins_dir = Path(app.root_path).parent / 'plugins' + + # Initialize components + self.registry = PluginRegistry(instance_path / 'plugins.json') + self.loader = PluginLoader(plugins_dir, self.registry) + self.migration_manager = PluginMigrationManager( + plugins_dir, + app.config.get('SQLALCHEMY_DATABASE_URI') + ) + + # Load enabled plugins + self._load_enabled_plugins() + + # Store on app for access + app.extensions['plugin_manager'] = self + + def _load_enabled_plugins(self) -> None: + """Load and register all enabled plugins.""" + plugins = self.loader.load_enabled_plugins(self._app, self._db) + + for name, plugin in plugins.items(): + self._register_plugin_components(plugin) + + def upgrade_all_plugins(self) -> Dict[str, str]: + """Run pending Alembic migrations for every discovered plugin. + + Returns {plugin_name: 'ok'|'no-migrations'|}. Skips + plugins with no migrations/ directory. Driven by the CLI + (`flask plugin upgrade-all`), which every deploy runs after + `flask db upgrade`. Each bundled plugin's chain begins with a + stamp-only anchor (the core chain already built its tables); later + per-plugin migrations extend that chain. See ADR-008. Idempotent. + """ + results: Dict[str, str] = {} + if not self.migration_manager: + return results + plugin_names = list(self.registry.list_installed().keys()) \ + if hasattr(self.registry, 'list_installed') else [] + if not plugin_names: + # Fall back to whatever the loader found on disk. + plugin_names = self.loader.discover_plugins() + for name in plugin_names: + if not self.migration_manager.has_pending_migrations(name): + results[name] = 'no-migrations' + continue + try: + ok = self.migration_manager.run_plugin_migrations(name) + results[name] = 'ok' if ok else 'failed' + except Exception as ex: + results[name] = f'error: {ex}' + return results + + def _register_plugin_components(self, plugin: BasePlugin) -> None: + """Register plugin's blueprint, models, CLI commands, etc.""" + # Register blueprint + blueprint = plugin.get_blueprint() + if blueprint: + prefix = plugin.meta.api_prefix + # Guard against two plugins claiming the same API prefix; Flask only + # rejects duplicate blueprint names, not overlapping url_prefixes, so + # an overlap would silently shadow routes. + if prefix in self._registered_prefixes: + raise ValueError( + f"Plugin {plugin.meta.name} api_prefix '{prefix}' is already " + f"claimed by another blueprint" + ) + self._app.register_blueprint(blueprint, url_prefix=prefix) + self._registered_prefixes.add(prefix) + logger.debug(f"Registered blueprint: {prefix}") + + # Register CLI commands + for cmd in plugin.get_cli_commands(): + self._app.cli.add_command(cmd) + + def discover_available(self) -> List[Dict]: + """ + Get list of all available plugins (installed or not). + Returns list of plugin info dicts. + """ + available = [] + + for name in self.loader.discover_plugins(): + plugin_class = self.loader.load_plugin_class(name) + if plugin_class: + try: + temp = plugin_class() + meta = temp.meta + state = self.registry.get(name) + + try: + config_schema = temp.get_config_schema() + except Exception: + config_schema = [] + try: + provisioning_note = temp.get_provisioning_note() + except Exception: + provisioning_note = None + manifest = self.loader.load_manifest(name) + available.append({ + 'name': meta.name, + # Human label for UIs. Manifest display_name wins; else + # title-case the machine name (right for most, e.g. + # "computers" -> "Computers"). + 'displayname': (manifest.get('display_name') + or meta.name.replace('_', ' ').title()), + 'version': meta.version, + 'description': meta.description, + 'author': meta.author, + 'dependencies': meta.dependencies, + 'installed': state is not None, + 'enabled': state.enabled if state else False, + 'installedat': state.installed_at if state else None, + 'config_schema': config_schema, + 'provisioning_note': provisioning_note, + 'default_enabled': manifest.get('default_enabled', True), + }) + except Exception as e: + logger.warning(f"Error inspecting plugin {name}: {e}") + + return available + + def install_plugin(self, name: str, run_migrations: bool = True) -> bool: + """ + Install a plugin. + + Steps: + 1. Verify plugin exists + 2. Check dependencies + 3. Run database migrations + 4. Register in registry + 5. Call plugin's on_install hook + """ + # Check if already installed + if self.registry.is_installed(name): + logger.warning(f"Plugin {name} is already installed") + return False + + # Read metadata from the manifest (single source of truth) instead of + # instantiating the plugin class just to inspect deps/version. + manifest = self.loader.load_manifest(name) + if not manifest: + logger.error(f"Plugin {name} not found") + return False + manifest_version = manifest.get('version') + + # Check dependencies + for dep in manifest.get('dependencies', []): + if not self.registry.is_installed(dep): + logger.error( + f"Plugin {name} requires {dep} to be installed first" + ) + return False + + # Run migrations + if run_migrations: + success = self.migration_manager.run_plugin_migrations(name) + if not success: + logger.error(f"Failed to run migrations for {name}") + return False + + # Register plugin + # Plugins that provision extra tables install disabled until a site + # opts in (manifest default_enabled=false). + self.registry.register(name, manifest_version, + enabled=manifest.get('default_enabled', True)) + + # Load the plugin + plugin = self.loader.load_plugin(name, self._app, self._db) + if plugin: + self._register_plugin_components(plugin) + self._seed_plugin_permissions(plugin) + plugin.on_install(self._app) + + logger.info(f"Installed plugin: {name} v{manifest_version}") + return True + + def _seed_plugin_permissions(self, plugin: BasePlugin) -> None: + """Idempotently create Permission rows for a plugin's declared perms. + + Runs at install and enable so a fresh plugin's RBAC permissions exist + without a separate `flask seed permissions` pass. Best-effort: a plugin + that raises must not abort the lifecycle.""" + try: + entries = plugin.get_permissions() or [] + except Exception: + logger.exception( + "get_permissions failed for %s", plugin.meta.name) + return + if not entries: + return + from shopdb.core.models import Permission + with self._app.app_context(): + created = Permission.seed_entries(entries) + if created: + self._db.session.commit() + logger.info( + "Seeded %d permission(s) for plugin %s", + created, plugin.meta.name) + + def uninstall_plugin(self, name: str, remove_data: bool = False) -> bool: + """ + Uninstall a plugin. + + Args: + name: Plugin name + remove_data: If True, run downgrade migrations to remove tables + """ + if not self.registry.is_installed(name): + logger.warning(f"Plugin {name} is not installed") + return False + + # Check if other plugins depend on this one + for other_name in self.registry.get_enabled_plugins(): + if other_name == name: + continue + other_plugin = self.loader.get_loaded_plugin(other_name) + if other_plugin and name in other_plugin.meta.dependencies: + logger.error( + f"Cannot uninstall {name}: {other_name} depends on it" + ) + return False + + # Get plugin instance + plugin = self.loader.get_loaded_plugin(name) + + # Call on_uninstall hook + if plugin: + plugin.on_uninstall(self._app) + + # Optionally remove data + if remove_data: + self.migration_manager.downgrade_plugin(name) + + # Unregister + self.registry.unregister(name) + + logger.info(f"Uninstalled plugin: {name}") + return True + + def enable_plugin(self, name: str) -> bool: + """Enable a disabled plugin.""" + if not self.registry.is_installed(name): + logger.error(f"Plugin {name} is not installed") + return False + + if self.registry.is_enabled(name): + logger.info(f"Plugin {name} is already enabled") + return True + + # Check dependencies are enabled. Read deps from the manifest, not by + # instantiating the plugin class (manifest is the single source of + # truth; instantiating fires __init__ side effects unnecessarily). + manifest = self.loader.load_manifest(name) + for dep in manifest.get('dependencies', []): + if not self.registry.is_enabled(dep): + logger.error(f"Cannot enable {name}: {dep} is not enabled") + return False + + self.registry.enable(name) + + # Surface (do not auto-apply) a plugin chain that is ahead of the DB, so + # an operator enabling a plugin knows to run `flask plugin upgrade-all`. + try: + if self.migration_manager and \ + self.migration_manager.has_unapplied_migrations(name): + logger.warning( + f"Plugin {name} has unapplied migrations; " + f"run 'flask plugin upgrade-all'" + ) + except Exception: + logger.debug(f"Could not check migration state for {name}") + + # Fire the on_enable hook best-effort. Do NOT register the blueprint + # here: Flask forbids register_blueprint after the first request, so + # routes/nav for a re-enabled plugin take effect on the next restart + # (symmetric with disable). + try: + plugin = self.loader.load_plugin(name, self._app, self._db) + if plugin: + self._seed_plugin_permissions(plugin) + plugin.on_enable(self._app) + except Exception: + logger.exception(f"on_enable hook failed for plugin {name}") + + logger.info(f"Enabled plugin: {name}") + return True + + def disable_plugin(self, name: str) -> bool: + """Disable an enabled plugin.""" + if not self.registry.is_enabled(name): + logger.info(f"Plugin {name} is already disabled") + return True + + # Check if other plugins depend on this one + for other_name in self.registry.get_enabled_plugins(): + if other_name == name: + continue + other_plugin = self.loader.get_loaded_plugin(other_name) + if other_plugin and name in other_plugin.meta.dependencies: + logger.error( + f"Cannot disable {name}: {other_name} depends on it" + ) + return False + + plugin = self.loader.get_loaded_plugin(name) + if plugin: + plugin.on_disable(self._app) + + self.registry.disable(name) + logger.info(f"Disabled plugin: {name}") + return True + + def get_plugin(self, name: str) -> Optional[BasePlugin]: + """Get a loaded plugin instance.""" + return self.loader.get_loaded_plugin(name) + + def get_all_plugins(self) -> Dict[str, BasePlugin]: + """Get all loaded plugins.""" + return self.loader.get_all_loaded() + + def get_service(self, name: str): + """Resolve a service exposed by an enabled plugin via get_services(). + + Consumer for the BasePlugin.get_services hook: searches enabled plugins + for one that registers `name` and returns the registered value (a service + class or factory). Returns None if no enabled plugin provides it. This is + how one plugin obtains another's service (e.g. the Zabbix service). + """ + for plugin_name, plugin in self.get_all_plugins().items(): + if not self.registry.is_enabled(plugin_name): + continue + try: + services = plugin.get_services() or {} + except Exception: + continue + if name in services: + return services[name] + return None + + +# Global plugin manager instance +plugin_manager = PluginManager() diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py new file mode 100644 index 00000000..ff7dd64f --- /dev/null +++ b/shopdb/plugins/alembic_template.py @@ -0,0 +1,166 @@ +"""Shared Alembic env.py logic for bundled plugins. + +Every bundled plugin that owns tables (computers, employees, knowledgebase, +machines, network, notifications, printers, slides, usb, warranty) has a +`migrations/env.py` that does the minimum: + + import os + os.environ['PLUGIN_NAME'] = 'computers' + from shopdb.plugins.alembic_template import run_migrations + run_migrations() + +This module wires the plugin's models into a MetaData object filtered to +only the tables that belong to that plugin, then runs Alembic in either +offline or online mode against the Flask app's configured engine. + +Ownership cutover (see ADR-008): the core Alembic chain created every table +that exists through its head (`7d16_directoryemployees`), including the plugin +tables. Each plugin's `0001` migration is therefore a stamp-only no-op that +just records the anchor revision in `alembic_version_`. NEW plugin +schema changes land as `plugins//migrations/000N` from here on, never in +the core chain. + +Plugin tables must be importable via `plugins..models`. Plugins +register their `__tablename__` set in PLUGIN_TABLE_OWNERS below so the +filter is explicit (avoids depending on import-side-effect global state). +""" +from __future__ import annotations + +import importlib +import logging +import os +from typing import Iterable + +from alembic import context +from sqlalchemy import MetaData, pool + +logger = logging.getLogger('alembic.env.plugin') + +# Explicit table-ownership map. Adding tables to a plugin requires updating +# this dict so the per-plugin migration knows which tables to include. +PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { + 'computers': ('computertypes', 'computers', 'computerinstalledapps', + 'accessprotocols', 'computeraccess'), + 'employees': ('directoryemployees',), + 'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes', + 'manifestentryhostnames', 'manifestentrymachinenumbers', + 'manifestinusechecks', 'manifestinusecheckprocesses', + 'manifestpublishedversions', 'manifestpayloads', + 'manifestenforcementreports', 'manifestenforcementresults', + 'pctypealiases'), + 'knowledgebase': ('knowledgebase',), + 'machines': ('machinetypes', 'machines'), + 'measuringtools': ('measuringtooltypes', 'measuringtools'), + 'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'), + 'notifications': ('notificationtypes', 'notifications'), + 'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'), + 'slides': ('tvslides',), + 'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'), + 'warranty': ('warranties', 'warrantyassets'), +} + + +def _get_plugin_metadata(plugin_name: str) -> MetaData: + """Import the plugin's models and return a MetaData containing only its + declared tables (filtered via PLUGIN_TABLE_OWNERS).""" + owned = set(PLUGIN_TABLE_OWNERS.get(plugin_name, ())) + if not owned: + raise RuntimeError( + f"PLUGIN_TABLE_OWNERS has no entry for plugin '{plugin_name}'. " + f"Update shopdb/plugins/alembic_template.py." + ) + + # Importing models attaches them to the global db.metadata. + importlib.import_module(f'plugins.{plugin_name}.models') + from shopdb.extensions import db + full = db.metadata + + plugin_md = MetaData() + for table in list(full.tables.values()): + if table.name in owned: + table.to_metadata(plugin_md) + return plugin_md + + +def create_plugin_tables(plugin_name: str): + """Emit CreateTable DDL for every table this plugin owns. Idempotent + via Alembic's batch_op.create_table behavior (raises if exists; the + baseline migration is meant to run against an empty schema). + + Called from each plugin's 0001_baseline.py upgrade() so the table + definitions stay sourced from the SQLAlchemy models rather than being + duplicated in handwritten Alembic ops. + """ + from alembic import op + from sqlalchemy.schema import CreateTable + + md = _get_plugin_metadata(plugin_name) + bind = op.get_bind() + # Sort by FK dependency so parent tables are created first. + for table in md.sorted_tables: + op.execute(str(CreateTable(table).compile(dialect=bind.dialect))) + + +def drop_plugin_tables(plugin_name: str): + """Mirror of create_plugin_tables for downgrade(). Drops in reverse FK + order.""" + from alembic import op + + md = _get_plugin_metadata(plugin_name) + for table in reversed(md.sorted_tables): + op.execute(f'DROP TABLE IF EXISTS "{table.name}"') + + +def run_migrations(): + """Entry point called by each plugin's migrations/env.py.""" + plugin_name = os.environ.get('PLUGIN_NAME') + if not plugin_name: + raise RuntimeError("PLUGIN_NAME env var must be set before run_migrations()") + + config = context.config + target_metadata = _get_plugin_metadata(plugin_name) + + # Per-plugin version table so each plugin's chain is independent of core + # Alembic's alembic_version table. + version_table = f'alembic_version_{plugin_name}' + + db_url = config.get_main_option('sqlalchemy.url') + if not db_url: + # Pull from the Flask app config if running inside an app context + # (e.g. via flask plugin migrate ). + try: + from flask import current_app + db_url = current_app.config['SQLALCHEMY_DATABASE_URI'] + config.set_main_option('sqlalchemy.url', db_url.replace('%', '%%')) + except Exception as ex: + raise RuntimeError( + "sqlalchemy.url not set and no Flask app context available. " + f"Original error: {ex}" + ) + + if context.is_offline_mode(): + context.configure( + url=db_url, + target_metadata=target_metadata, + literal_binds=True, + version_table=version_table, + include_schemas=False, + ) + with context.begin_transaction(): + context.run_migrations() + else: + # Build the engine straight from the resolved URL. The plugin manager + # drives this via a programmatic alembic Config (no ini file), so + # config.get_section returns an empty dict and engine_from_config would + # find no sqlalchemy.url. db_url is already resolved above. + from sqlalchemy import create_engine + connectable = create_engine(db_url, poolclass=pool.NullPool) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + version_table=version_table, + include_schemas=False, + ) + with context.begin_transaction(): + context.run_migrations() diff --git a/shopdb/plugins/base.py b/shopdb/plugins/base.py new file mode 100644 index 00000000..6a39e2a2 --- /dev/null +++ b/shopdb/plugins/base.py @@ -0,0 +1,350 @@ +"""Base plugin class that all plugins must inherit from.""" + +from abc import ABC, abstractmethod +from typing import List, Dict, Optional, Type +from dataclasses import dataclass, field +from flask import Flask, Blueprint + + +@dataclass +class PluginMeta: + """Plugin metadata container.""" + + name: str + version: str + description: str + 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" + api_prefix: str = None + + def __post_init__(self): + if self.api_prefix is None: + self.api_prefix = f"/api/{self.name.replace('_', '-')}" + + +class BasePlugin(ABC): + """ + Base class for all ShopDB plugins. + + Plugins must implement: + - meta: PluginMeta instance + - get_blueprint(): Return Flask Blueprint for API routes + - get_models(): Return list of SQLAlchemy model classes + + Optionally implement: + - init_app(app, db): Custom initialization + - get_cli_commands(): Return Click commands + - get_services(): Return service classes + - on_install(): Called when plugin is installed + - on_uninstall(): Called when plugin is uninstalled + - on_enable(): Called when plugin is enabled + - on_disable(): Called when plugin is disabled + """ + + @property + @abstractmethod + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + pass + + @abstractmethod + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + pass + + @abstractmethod + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + pass + + def init_app(self, app: Flask, db) -> None: + """ + Initialize plugin with Flask app. + Override for custom initialization. + """ + pass + + def get_cli_commands(self) -> List: + """Return list of Click command groups/commands.""" + return [] + + def get_services(self) -> Dict[str, Type]: + """Return dict of service name -> service class.""" + return {} + + def get_provisioning_note(self) -> Optional[Dict]: + """Transparency note shown when a site enables this plugin. + + Return None for plugins that need no special setup. For plugins that + create extra tables (e.g. a self-hosted directory or USB tables), return: + { + 'tables': ['directoryemployees', ...], # created in the shopdb DB + 'note': 'Plain-language what/why.', + 'docs': 'plugins//README.md', # where the schema lives + } + The setup wizard shows this the moment the plugin is checked. + """ + return None + + def get_config_schema(self) -> List[Dict]: + """Declare the config fields this plugin needs, for the setup wizard. + + Each field is a dict: + key - the Setting key (non-secret) it maps to + label - human label + type - 'text' | 'number' | 'password' + secret - True for credentials; these are NOT stored in the DB, the + wizard emits an .env line for the operator to paste instead + envvar - (secret only) the .env variable name to emit + default - optional default shown as a placeholder + help - optional hint + Return [] (default) if the plugin needs no configuration. + """ + return [] + + def get_setting(self, key: str, default=None): + """Read a plugin-scoped setting from the core Setting store. + + Settings are namespaced by plugin name to avoid collisions + across plugins. The on-disk key is `plugin..`. + + Returns the typed value (string, integer, boolean, etc.) or + the default if not set. + """ + from shopdb.core.models import Setting + namespaced_key = f'plugin.{self.meta.name}.{key}' + return Setting.get(namespaced_key, default) + + def set_setting(self, key: str, value, valuetype: str = 'string', + description: str = None) -> None: + """Write a plugin-scoped setting to the core Setting store. + + Settings are namespaced by plugin name. Persists immediately and + survives restarts. + """ + from shopdb.core.models import Setting + namespaced_key = f'plugin.{self.meta.name}.{key}' + Setting.set( + namespaced_key, + value, + valuetype=valuetype, + category=f'plugin.{self.meta.name}', + description=description, + ) + + def get_collector_schema(self) -> Optional[Dict]: + """Return JSON Schema describing the collector payload for this plugin. + + Return None if the plugin does not accept collector input. + + See ADR-006 for the contract. The schema must include: + - 'identityfield': name of the field that uniquely identifies an + asset across submissions (e.g., 'hostname' for PCs, + 'macaddress' for network devices). Used for idempotent upsert. + - 'fields': JSON Schema definitions for the rest of the payload. + + Plugins returning a non-None schema have an endpoint at + /api/collector/ auto-registered by the loader. + """ + return None + + def apply_collector_payload(self, payload: Dict) -> Dict: + """Idempotently upsert an asset from a validated collector payload. + + Called by the generic /api/collector/ endpoint after the + payload passed schema validation. Plugins that return a schema from + get_collector_schema must implement this. Return a dict with at least: + - 'action': 'created' | 'updated' | 'noop' + - 'assetid': the affected asset id (or None) + - 'warnings': list[str] + """ + raise NotImplementedError( + f"{self.meta.name} declares a collector schema but does not " + f"implement apply_collector_payload" + ) + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed via CLI.""" + pass + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled via CLI.""" + pass + + def on_enable(self, app: Flask) -> None: + """Called when plugin is enabled.""" + pass + + def on_disable(self, app: Flask) -> None: + """Called when plugin is disabled.""" + pass + + def get_dashboard_widgets(self) -> List[Dict]: + """ + Return dashboard widget definitions. + + Each widget: { + 'name': str, + 'component': str, # Frontend component name + 'endpoint': str, # API endpoint for data + 'size': str, # 'small', 'medium', 'large' + 'position': int # Order on dashboard + } + """ + return [] + + def get_navigation_items(self) -> List[Dict]: + """ + Return navigation menu items. + + Each item: { + 'name': str, + 'icon': str, + 'route': str, + 'position': int, + 'children': [] + } + """ + return [] + + def get_permissions(self) -> List: + """ + Return the RBAC permissions this plugin owns. + + Each entry is a (name, description, category) tuple, matching the core + permission catalog shape (dicts with those keys are also accepted): + + [ + ('machines.view', 'View machines', 'machines'), + ('machines.create', 'Create machines', 'machines'), + ('machines.edit', 'Edit machines', 'machines'), + ('machines.delete', 'Delete machines', 'machines'), + ] + + A plugin owns the permission names its own routes enforce via + require_permission; core no longer accumulates them. The names must + follow the naming convention (lowercase dotted, e.g. `machines.edit`). + + Consumed by full_permission_catalog(): core permissions plus every + ENABLED plugin's get_permissions(). That catalog backs `flask seed + permissions`, the role-management grid (GET /api/users/permissions), + and API-token scope validation. Plugin install/enable also seeds the + plugin's own permissions idempotently. + + Disabled plugins are skipped by the catalog, so their permissions are + no longer offered for new scope grants or new role assignments. The + Permission ROWS already in the database are NOT deleted, so roles that + already reference them keep working until an admin edits the role. A + broken plugin is isolated in prod and re-raised in dev/test. + + Return [] (the default) if the plugin needs no permissions. + """ + return [] + + def get_reports(self) -> List[Dict]: + """ + Return report card definitions for the Reports hub. + + Each entry: { + 'id': str, # stable report id + 'name': str, # card title + 'description': str, # one-line blurb + 'category': str, # grouping key (lowercase) + # plus EXACTLY ONE of: + 'route': str, # frontend path for a dedicated report page + 'endpoint': str, # API endpoint for generic inline rendering + } + + Consumed by GET /api/reports, which merges these after the static core + reports. Disabled plugins are skipped by the consumer. + """ + return [] + + def get_settings_cards(self) -> List[Dict]: + """ + Return settings-catalog card definitions (ADR-010). + + Each card contributes an entry to the settings rail + landing overview + without a plugin hand-editing the core settingsNav.js catalog. + + Each card: { + 'group': str, # rail group title (created if new) + 'to': str, # settings route the card links to + 'icon': str, # string key, mapped to a Lucide icon core-side + 'title': str, # card title + 'description': str, # one-line blurb + 'position': int, # order within the group + } + + Consumed by GET /api/pluginui/settings-cards, which merges enabled + plugins' cards into the core catalog. Disabled plugins are skipped; + a broken plugin is isolated in prod, re-raised in dev/test. + """ + return [] + + def get_asset_panels(self) -> List[Dict]: + """ + Return asset-detail extension-panel definitions (ADR-010). + + A generic core AssetPanel component renders each panel on the asset + detail pages whose type matches, fetching the panel's endpoint. This + replaces hand-composing a plugin panel component into each detail view. + + Each panel: { + 'id': str, # stable panel id + 'title': str, # panel heading + 'assettypes': List[str], # AssetType keys it appears on; ['*'] = all + 'endpoint': str, # data endpoint (may contain {assetid}) + 'render': str, # 'keyvalue' | 'table' | 'badge' + 'position': int, # order among panels + } + + Consumed by GET /api/pluginui/asset-panels?assetid=, which returns + the panels matching that asset's type. Disabled plugins + are skipped; a broken plugin is isolated in prod, re-raised in dev/test. + A panel needing bespoke UI is out of scope for the data-only hook. + """ + return [] + + def get_map_overlays(self) -> List[Dict]: + """ + Return shop-floor map overlay/decoration definitions (ADR-010). + + The map is data-driven off asset types + positions; an overlay adds + decoration data (a badge or ring on already-placed markers) plus a + legend entry, without the plugin shipping any map code. + + Each overlay: { + 'id': str, # stable overlay id + 'label': str, # legend label + 'endpoint': str, # returns [{assetid, color, label}] to decorate + 'style': str, # 'badge' | 'ring' + 'legend': bool, # True to add a legend entry + } + + Consumed by GET /api/pluginui/map-overlays. Disabled plugins are + skipped; a broken plugin is isolated in prod, re-raised in dev/test. + """ + return [] + + def get_asset_presentation(self) -> List[Dict]: + """ + Return asset-type presentation/routing definitions (ADR-010). + + Declares how a plugin-owned asset type renders in global-search rows + and cross-links: which icon to show and where the detail link points, + so core never hardcodes a plugin's route or icon. + + Each entry: { + 'assettype': str, # AssetType.assettype key the plugin owns + 'icon': str, # string key, mapped to a Lucide icon core-side + 'label': str, # human label for the type + 'route': str, # detail-route pattern (may contain {assetid}) + } + + Consumed by GET /api/pluginui/asset-presentation. Disabled plugins are + skipped; a broken plugin is isolated in prod, re-raised in dev/test. + """ + return [] diff --git a/shopdb/plugins/cli.py b/shopdb/plugins/cli.py new file mode 100644 index 00000000..66459673 --- /dev/null +++ b/shopdb/plugins/cli.py @@ -0,0 +1,281 @@ +"""Flask CLI commands for plugin management.""" + +import click +from flask import current_app +from flask.cli import with_appcontext + + +@click.group('plugin') +def plugin_cli(): + """Plugin management commands.""" + pass + + +@plugin_cli.command('list') +@with_appcontext +def list_plugins(): + """List all available plugins.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + return + + plugins = pm.discover_available() + + if not plugins: + click.echo("No plugins found in plugins directory.") + return + + # Format output + click.echo("") + click.echo(click.style("Available Plugins:", fg='cyan', bold=True)) + click.echo("-" * 60) + + for p in plugins: + if p['enabled']: + status = click.style("[Enabled]", fg='green') + elif p['installed']: + status = click.style("[Disabled]", fg='yellow') + else: + status = click.style("[Available]", fg='white') + + click.echo(f" {p['name']:20} v{p['version']:10} {status}") + if p['description']: + click.echo(f" {p['description'][:55]}...") + if p['dependencies']: + deps = ', '.join(p['dependencies']) + click.echo(f" Dependencies: {deps}") + + click.echo("") + + +@plugin_cli.command('install') +@click.argument('name') +@click.option('--skip-migrations', is_flag=True, help='Skip database migrations') +@with_appcontext +def install_plugin(name: str, skip_migrations: bool): + """ + Install a plugin. + + Usage: flask plugin install printers + """ + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + click.echo(f"Installing plugin: {name}") + + if pm.install_plugin(name, run_migrations=not skip_migrations): + click.echo(click.style(f"Successfully installed {name}", fg='green')) + else: + click.echo(click.style(f"Failed to install {name}", fg='red')) + raise SystemExit(1) + + +@plugin_cli.command('uninstall') +@click.argument('name') +@click.option('--remove-data', is_flag=True, help='Remove plugin database tables') +@click.confirmation_option(prompt='Are you sure you want to uninstall this plugin?') +@with_appcontext +def uninstall_plugin(name: str, remove_data: bool): + """ + Uninstall a plugin. + + Usage: flask plugin uninstall printers + """ + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + click.echo(f"Uninstalling plugin: {name}") + + if pm.uninstall_plugin(name, remove_data=remove_data): + click.echo(click.style(f"Successfully uninstalled {name}", fg='green')) + else: + click.echo(click.style(f"Failed to uninstall {name}", fg='red')) + raise SystemExit(1) + + +@plugin_cli.command('enable') +@click.argument('name') +@with_appcontext +def enable_plugin(name: str): + """Enable a disabled plugin.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + if pm.enable_plugin(name): + click.echo(click.style(f"Enabled {name}", fg='green')) + # Nudge the operator if the plugin's chain is ahead of the DB. + try: + if pm.migration_manager and \ + pm.migration_manager.has_unapplied_migrations(name): + click.echo(click.style( + f" {name} has unapplied migrations - " + f"run 'flask plugin upgrade-all'", fg='yellow')) + except Exception: + pass + else: + click.echo(click.style(f"Failed to enable {name}", fg='red')) + raise SystemExit(1) + + +@plugin_cli.command('disable') +@click.argument('name') +@with_appcontext +def disable_plugin(name: str): + """Disable an enabled plugin.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + if pm.disable_plugin(name): + click.echo(click.style(f"Disabled {name}", fg='green')) + else: + click.echo(click.style(f"Failed to disable {name}", fg='red')) + raise SystemExit(1) + + +@plugin_cli.command('info') +@click.argument('name') +@with_appcontext +def plugin_info(name: str): + """Show detailed information about a plugin.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + plugin_class = pm.loader.load_plugin_class(name) + if not plugin_class: + click.echo(click.style(f"Plugin {name} not found", fg='red')) + raise SystemExit(1) + + try: + temp = plugin_class() + meta = temp.meta + except Exception as e: + click.echo(click.style(f"Error loading plugin: {e}", fg='red')) + raise SystemExit(1) + + state = pm.registry.get(name) + + click.echo("") + click.echo("=" * 50) + click.echo(click.style(f"Plugin: {meta.name}", fg='cyan', bold=True)) + click.echo("=" * 50) + click.echo(f"Version: {meta.version}") + click.echo(f"Description: {meta.description}") + click.echo(f"Author: {meta.author or 'Unknown'}") + click.echo(f"API Prefix: {meta.api_prefix}") + click.echo(f"Dependencies: {', '.join(meta.dependencies) or 'None'}") + click.echo(f"Core Version: {meta.core_version}") + click.echo("") + + if state: + status = click.style('Enabled', fg='green') if state.enabled else click.style('Disabled', fg='yellow') + click.echo(f"Status: {status}") + click.echo(f"Installed: {state.installed_at}") + click.echo(f"Migrations: {len(state.migrations_applied)} applied") + else: + click.echo(f"Status: {click.style('Not installed', fg='white')}") + + click.echo("") + + +@plugin_cli.command('new') +@click.argument('name') +@click.option('--description', default='', help='One-sentence plugin description') +@click.option('--overwrite', is_flag=True, help='Overwrite existing plugin directory') +@with_appcontext +def new_plugin(name: str, description: str, overwrite: bool): + """Scaffold a new plugin from the bundled templates. + + Usage: flask plugin new cameras --description "Tracks shop-floor cameras" + """ + from pathlib import Path + from .scaffolder import scaffold_plugin, ScaffoldError + + plugins_dir = Path(current_app.root_path).parent / 'plugins' + + if not description: + description = f'{name.capitalize()} plugin (TODO: replace this description)' + + try: + target = scaffold_plugin( + name=name, + description=description, + plugins_dir=plugins_dir, + overwrite=overwrite, + ) + except ScaffoldError as e: + click.echo(click.style(f'Scaffold failed: {e}', fg='red')) + raise SystemExit(1) + + click.echo(click.style(f'Created plugin at {target}', fg='green')) + click.echo('') + click.echo('Next steps:') + click.echo(f' 1. Edit plugins/{name}/models/{name}.py with your domain fields') + click.echo(f' 2. Edit plugins/{name}/api/routes.py with your endpoints') + click.echo(f' 3. Add plugins/{name}/migrations/ with an Alembic chain that') + click.echo(f' creates your tables (per-plugin chain, NOT the core chain;') + click.echo(f' see ADR-008). Register the tables in PLUGIN_TABLE_OWNERS.') + click.echo(f' 4. Run: flask plugin install {name}') + click.echo(f' 5. Run: flask plugin upgrade-all') + click.echo(f' 6. Run: pytest plugins/{name}/tests/') + + +@plugin_cli.command('migrate') +@click.argument('name') +@click.option('--revision', default='head', help='Target revision') +@with_appcontext +def migrate_plugin(name: str, revision: str): + """Run migrations for a specific plugin.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + if not pm.registry.is_installed(name): + click.echo(click.style(f"Plugin {name} is not installed", fg='red')) + raise SystemExit(1) + + click.echo(f"Running migrations for {name}...") + + if pm.migration_manager.run_plugin_migrations(name, revision): + click.echo(click.style("Migrations completed", fg='green')) + else: + click.echo(click.style("Migration failed", fg='red')) + raise SystemExit(1) + + +@plugin_cli.command('upgrade-all') +@with_appcontext +def upgrade_all_plugins(): + """Run pending migrations for every discovered plugin. + + Idempotent. Run this after `flask db upgrade` on every deploy and + upgrade. It stamps each bundled plugin's anchor revision into + alembic_version_ and applies any per-plugin migrations added + after the ownership cutover (ADR-008). Safe to re-run at head. + """ + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + results = pm.upgrade_all_plugins() + if not results: + click.echo("No plugins discovered.") + return + for name, status in sorted(results.items()): + if status == 'ok': + click.echo(click.style(f" {name:20} ok", fg='green')) + elif status == 'no-migrations': + click.echo(click.style(f" {name:20} no migrations", fg='yellow')) + else: + click.echo(click.style(f" {name:20} {status}", fg='red')) diff --git a/shopdb/plugins/loader.py b/shopdb/plugins/loader.py new file mode 100644 index 00000000..f59d079e --- /dev/null +++ b/shopdb/plugins/loader.py @@ -0,0 +1,276 @@ +"""Plugin discovery and loading. + +The loader reads each plugin's manifest.json before instantiating the +plugin class. Dependency sorting and contract-version compatibility +checks operate on manifests, not plugin instances. The plugin class is +imported and instantiated only after the manifest passes validation. + +Failure policy (per the enforcing-plugin-contract skill): +- In dev/test (app.config.DEBUG or TESTING true): re-raise the original + exception so failures are loud. +- In production: log the failure with full context and exclude the + plugin from registration. The framework keeps booting. +""" + +import importlib +import importlib.util +import json +import logging +from pathlib import Path +from typing import Dict, List, Type, Optional + +from flask import Flask +from packaging.specifiers import SpecifierSet, InvalidSpecifier +from packaging.version import Version, InvalidVersion + +from .base import BasePlugin +from .registry import PluginRegistry +from ..exceptions import ( + PluginError, + PluginNotFoundError, + PluginContractError, + PluginVersionError, + PluginDependencyError, +) + +logger = logging.getLogger(__name__) + + +class PluginLoader: + """Discovers and loads plugins from the plugins directory.""" + + def __init__(self, plugins_dir: Path, registry: PluginRegistry): + self.plugins_dir = plugins_dir + self.registry = registry + self._loaded_plugins: Dict[str, BasePlugin] = {} + self._plugin_classes: Dict[str, Type[BasePlugin]] = {} + self._manifests: Dict[str, dict] = {} + + def _is_strict_mode(self, app: Optional[Flask]) -> bool: + """Return True if loader should re-raise instead of isolating failures.""" + if app is None: + return True + return bool(app.config.get('DEBUG') or app.config.get('TESTING')) + + def _handle_failure(self, app: Optional[Flask], exc: Exception, name: str) -> None: + """In strict mode re-raise; in production log with context and continue.""" + if self._is_strict_mode(app): + raise exc + logger.exception(f'Plugin {name} failed to load: {exc}') + + def discover_plugins(self) -> List[str]: + """Discover available plugins in plugins directory.""" + available = [] + if not self.plugins_dir.exists(): + return available + + for item in self.plugins_dir.iterdir(): + if not item.is_dir(): + continue + if (item / 'plugin.py').exists(): + available.append(item.name) + + return available + + def load_manifest(self, name: str) -> dict: + """Load the plugin's manifest.json. + + Raises PluginNotFoundError if the manifest does not exist or is + unparseable. The result is cached. + """ + if name in self._manifests: + return self._manifests[name] + + manifest_path = self.plugins_dir / name / 'manifest.json' + if not manifest_path.exists(): + raise PluginNotFoundError( + f'Plugin {name} has no manifest.json at {manifest_path}', + plugin_name=name, + ) + + try: + with open(manifest_path) as f: + manifest = json.load(f) + except json.JSONDecodeError as e: + raise PluginContractError( + f'Plugin {name} has invalid manifest.json: {e}', + plugin_name=name, + ) from e + + for required in ('name', 'version', 'description'): + if not manifest.get(required): + raise PluginContractError( + f'Plugin {name} manifest.json missing required field "{required}"', + plugin_name=name, + ) + + if manifest['name'] != name: + raise PluginContractError( + f'Plugin directory "{name}" does not match manifest name "{manifest["name"]}"', + plugin_name=name, + ) + + self._manifests[name] = manifest + return manifest + + def check_contract_version(self, name: str, contract_version: str) -> None: + """Verify the plugin's core_version range includes contract_version. + + Raises PluginVersionError on mismatch. + """ + manifest = self.load_manifest(name) + core_version_spec = manifest.get('core_version', '') + + if not core_version_spec: + return + + try: + specifier = SpecifierSet(core_version_spec) + framework_version = Version(contract_version) + except (InvalidSpecifier, InvalidVersion) as e: + raise PluginContractError( + f'Plugin {name} has invalid version specifier "{core_version_spec}": {e}', + plugin_name=name, + ) from e + + if framework_version not in specifier: + raise PluginVersionError( + f'Plugin {name} requires core_version {core_version_spec} ' + f'but framework is at {contract_version}', + plugin_name=name, + ) + + def load_plugin_class(self, name: str) -> Type[BasePlugin]: + """Import the plugin's plugin.py and return the BasePlugin subclass. + + Raises PluginNotFoundError if plugin.py is missing. + Raises PluginContractError if the module has no BasePlugin subclass + or import fails. + """ + if name in self._plugin_classes: + return self._plugin_classes[name] + + plugin_module_path = self.plugins_dir / name / 'plugin.py' + if not plugin_module_path.exists(): + raise PluginNotFoundError( + f'Plugin {name} plugin.py not found at {plugin_module_path}', + plugin_name=name, + ) + + try: + spec = importlib.util.spec_from_file_location( + f'plugins.{name}.plugin', plugin_module_path, + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + except Exception as e: + raise PluginContractError( + f'Plugin {name} import failed: {e}', + plugin_name=name, + ) from e + + for attr_name in dir(module): + attr = getattr(module, attr_name) + if (isinstance(attr, type) + and issubclass(attr, BasePlugin) + and attr is not BasePlugin): + self._plugin_classes[name] = attr + return attr + + raise PluginContractError( + f'Plugin {name} module has no BasePlugin subclass', + plugin_name=name, + ) + + def load_plugin(self, name: str, app: Flask, db) -> Optional[BasePlugin]: + """Load and instantiate a plugin. + + Returns the plugin instance on success. In strict mode (dev/test), + any failure raises. In production, returns None and logs the + failure with full context. + """ + if name in self._loaded_plugins: + return self._loaded_plugins[name] + + try: + from shopdb import __contract_version__ + + manifest = self.load_manifest(name) + self.check_contract_version(name, __contract_version__) + + for dep in manifest.get('dependencies', []): + if not self.registry.is_enabled(dep): + raise PluginDependencyError( + f'Plugin {name} requires {dep} which is not enabled', + plugin_name=name, + ) + + plugin_class = self.load_plugin_class(name) + plugin = plugin_class() + plugin.init_app(app, db) + + self._loaded_plugins[name] = plugin + return plugin + + except PluginError as e: + self._handle_failure(app, e, name) + return None + except Exception as e: + wrapped = PluginError( + f'Unexpected failure loading plugin {name}: {e}', + plugin_name=name, + ) + self._handle_failure(app, wrapped, name) + return None + + def load_enabled_plugins(self, app: Flask, db) -> Dict[str, BasePlugin]: + """Load all enabled plugins in dependency order.""" + loaded = {} + + enabled = self.registry.get_enabled_plugins() + sorted_plugins = self._sort_by_dependencies(enabled) + + for name in sorted_plugins: + plugin = self.load_plugin(name, app, db) + if plugin: + loaded[name] = plugin + logger.info(f'Loaded plugin: {name} v{plugin.meta.version}') + + return loaded + + def _sort_by_dependencies(self, plugin_names: List[str]) -> List[str]: + """Sort plugins so dependencies come first. + + Reads dependencies from manifest.json directly; does not + instantiate plugin classes during sort. + """ + sorted_list = [] + visited = set() + + def visit(name): + if name in visited: + return + visited.add(name) + + try: + manifest = self.load_manifest(name) + for dep in manifest.get('dependencies', []): + if dep in plugin_names: + visit(dep) + except PluginError: + pass + + sorted_list.append(name) + + for name in plugin_names: + visit(name) + + return sorted_list + + def get_loaded_plugin(self, name: str) -> Optional[BasePlugin]: + """Get an already loaded plugin.""" + return self._loaded_plugins.get(name) + + def get_all_loaded(self) -> Dict[str, BasePlugin]: + """Get all loaded plugins.""" + return self._loaded_plugins.copy() diff --git a/shopdb/plugins/migrations.py b/shopdb/plugins/migrations.py new file mode 100644 index 00000000..77da4fba --- /dev/null +++ b/shopdb/plugins/migrations.py @@ -0,0 +1,207 @@ +"""Plugin migration management using Alembic.""" + +from pathlib import Path +from typing import Optional +import logging +import subprocess +import sys + +logger = logging.getLogger(__name__) + + +class PluginMigrationManager: + """ + Manages database migrations for plugins. + Each plugin has its own migrations directory. + """ + + def __init__(self, plugins_dir: Path, database_url: str): + self.plugins_dir = plugins_dir + self.database_url = database_url + + def get_migrations_dir(self, plugin_name: str) -> Optional[Path]: + """Get migrations directory for a plugin.""" + migrations_dir = self.plugins_dir / plugin_name / 'migrations' + if migrations_dir.exists(): + return migrations_dir + return None + + def run_plugin_migrations( + self, + plugin_name: str, + revision: str = 'head' + ) -> bool: + """ + Run migrations for a plugin. + + Uses flask db upgrade with the plugin's migrations directory. + """ + migrations_dir = self.get_migrations_dir(plugin_name) + + if not migrations_dir: + logger.info(f"No migrations directory for plugin {plugin_name}") + return True # No migrations to run + + try: + # Use alembic directly with plugin's migrations + from alembic.config import Config + from alembic import command + + config = Config() + config.set_main_option('script_location', str(migrations_dir)) + config.set_main_option('sqlalchemy.url', self.database_url) + + # Use plugin-specific version table + config.set_main_option( + 'version_table', + f'alembic_version_{plugin_name}' + ) + + command.upgrade(config, revision) + logger.info(f"Migrations completed for {plugin_name}") + return True + + except ImportError: + # Fallback to subprocess if alembic not available in context + logger.warning("Using subprocess for migrations") + return self._run_migrations_subprocess(plugin_name, revision) + + except Exception as e: + logger.error(f"Migration failed for {plugin_name}: {e}") + return False + + def _run_migrations_subprocess( + self, + plugin_name: str, + revision: str = 'head' + ) -> bool: + """Run migrations via subprocess as fallback.""" + migrations_dir = self.get_migrations_dir(plugin_name) + if not migrations_dir: + return True + + try: + result = subprocess.run( + [ + sys.executable, '-m', 'alembic', + '-c', str(migrations_dir / 'alembic.ini'), + 'upgrade', revision + ], + capture_output=True, + text=True, + env={ + **dict(__import__('os').environ), + 'DATABASE_URL': self.database_url + } + ) + + if result.returncode != 0: + logger.error(f"Migration error: {result.stderr}") + return False + + return True + + except Exception as e: + logger.error(f"Migration subprocess failed: {e}") + return False + + def downgrade_plugin( + self, + plugin_name: str, + revision: str = 'base' + ) -> bool: + """ + Downgrade/rollback plugin migrations. + """ + migrations_dir = self.get_migrations_dir(plugin_name) + + if not migrations_dir: + return True + + try: + from alembic.config import Config + from alembic import command + + config = Config() + config.set_main_option('script_location', str(migrations_dir)) + config.set_main_option('sqlalchemy.url', self.database_url) + config.set_main_option( + 'version_table', + f'alembic_version_{plugin_name}' + ) + + command.downgrade(config, revision) + logger.info(f"Downgrade completed for {plugin_name}") + return True + + except Exception as e: + logger.error(f"Downgrade failed for {plugin_name}: {e}") + return False + + def get_current_revision(self, plugin_name: str) -> Optional[str]: + """Get current migration revision for a plugin.""" + migrations_dir = self.get_migrations_dir(plugin_name) + if not migrations_dir: + return None + + try: + from alembic.config import Config + from alembic.script import ScriptDirectory + + config = Config() + config.set_main_option('script_location', str(migrations_dir)) + + script = ScriptDirectory.from_config(config) + return script.get_current_head() + + except Exception: + return None + + def has_pending_migrations(self, plugin_name: str) -> bool: + """Check if plugin has any migration scripts on disk. + + File-level check only (no DB): True when the plugin ships a + migrations/versions dir with at least one script. Used by + upgrade-all to decide whether the plugin participates in the + per-plugin Alembic flow at all. + """ + migrations_dir = self.get_migrations_dir(plugin_name) + if not migrations_dir: + return False + + versions_dir = migrations_dir / 'versions' + if not versions_dir.exists(): + return False + + # Any migration files on disk? + migration_files = list(versions_dir.glob('*.py')) + return len(migration_files) > 0 + + def get_applied_revision(self, plugin_name: str) -> Optional[str]: + """Return the revision stamped in alembic_version_ in the DB, + or None if the plugin chain has never been stamped (or on any error).""" + migrations_dir = self.get_migrations_dir(plugin_name) + if not migrations_dir or not self.database_url: + return None + try: + from sqlalchemy import create_engine + from alembic.runtime.migration import MigrationContext + + engine = create_engine(self.database_url) + with engine.connect() as connection: + context = MigrationContext.configure( + connection, + opts={'version_table': f'alembic_version_{plugin_name}'}, + ) + return context.get_current_revision() + except Exception: + return None + + def has_unapplied_migrations(self, plugin_name: str) -> bool: + """True when the plugin's on-disk chain head is ahead of what the DB + has stamped. Compares the script head against alembic_version_. + Best-effort: returns False when it cannot tell (no chain / no DB).""" + head = self.get_current_revision(plugin_name) # script head on disk + if not head: + return False + return self.get_applied_revision(plugin_name) != head diff --git a/shopdb/plugins/registry.py b/shopdb/plugins/registry.py new file mode 100644 index 00000000..322be821 --- /dev/null +++ b/shopdb/plugins/registry.py @@ -0,0 +1,140 @@ +"""Plugin registry for tracking installed and enabled plugins.""" + +import json +from pathlib import Path +from typing import Dict, List, Optional +from dataclasses import dataclass, field, asdict +from datetime import datetime, timezone + + +@dataclass +class PluginState: + """Persistent state for a plugin.""" + + name: str + version: str + installed_at: str + enabled: bool = True + migrations_applied: List[str] = field(default_factory=list) + config: Dict = field(default_factory=dict) + + +class PluginRegistry: + """ + Manages plugin state persistence. + Stores state in JSON file in instance folder. + """ + + def __init__(self, state_file: Path): + self.state_file = state_file + self._plugins: Dict[str, PluginState] = {} + self._load() + + def _load(self) -> None: + """Load registry from file.""" + if self.state_file.exists(): + try: + with open(self.state_file, 'r') as f: + data = json.load(f) + for name, state_data in data.get('plugins', {}).items(): + self._plugins[name] = PluginState(**state_data) + except (json.JSONDecodeError, TypeError): + # Corrupted file, start fresh + self._plugins = {} + self._migrate_renamed_plugins() + + def _migrate_renamed_plugins(self) -> None: + """Upgrade path for the equipment -> machines plugin rename. + + Existing installs carry an 'equipment' entry in plugins.json. When the + renamed plugins/machines directory exists and no 'machines' entry does, + carry the state over under the new name and persist. + """ + if 'equipment' not in self._plugins or 'machines' in self._plugins: + return + machines_dir = Path(__file__).resolve().parents[2] / 'plugins' / 'machines' + if not machines_dir.exists(): + return + state = self._plugins.pop('equipment') + state.name = 'machines' + self._plugins['machines'] = state + self._save() + + def _save(self) -> None: + """Save registry to file.""" + self.state_file.parent.mkdir(parents=True, exist_ok=True) + with open(self.state_file, 'w') as f: + json.dump({ + 'plugins': { + name: asdict(state) + for name, state in self._plugins.items() + } + }, f, indent=2) + + def register(self, name: str, version: str, enabled: bool = True) -> PluginState: + """Register a newly installed plugin. enabled=False leaves it off until a + site opts in (used for plugins that provision extra tables).""" + state = PluginState( + name=name, + version=version, + installed_at=datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + enabled=enabled + ) + self._plugins[name] = state + self._save() + return state + + def unregister(self, name: str) -> None: + """Remove plugin from registry.""" + if name in self._plugins: + del self._plugins[name] + self._save() + + def get(self, name: str) -> Optional[PluginState]: + """Get plugin state.""" + return self._plugins.get(name) + + def is_installed(self, name: str) -> bool: + """Check if plugin is installed.""" + return name in self._plugins + + def is_enabled(self, name: str) -> bool: + """Check if plugin is enabled.""" + state = self._plugins.get(name) + return state.enabled if state else False + + def enable(self, name: str) -> None: + """Enable a plugin.""" + if name in self._plugins: + self._plugins[name].enabled = True + self._save() + + def disable(self, name: str) -> None: + """Disable a plugin.""" + if name in self._plugins: + self._plugins[name].enabled = False + self._save() + + def get_enabled_plugins(self) -> List[str]: + """Get list of enabled plugin names.""" + return [ + name for name, state in self._plugins.items() + if state.enabled + ] + + def add_migration(self, name: str, revision: str) -> None: + """Record that a migration was applied.""" + if name in self._plugins: + if revision not in self._plugins[name].migrations_applied: + self._plugins[name].migrations_applied.append(revision) + self._save() + + def get_all(self) -> Dict[str, PluginState]: + """Get all registered plugins.""" + return self._plugins.copy() + + def update_config(self, name: str, config: Dict) -> None: + """Update plugin configuration.""" + if name in self._plugins: + self._plugins[name].config.update(config) + self._save() diff --git a/shopdb/plugins/scaffolder.py b/shopdb/plugins/scaffolder.py new file mode 100644 index 00000000..ea6eebe7 --- /dev/null +++ b/shopdb/plugins/scaffolder.py @@ -0,0 +1,236 @@ +"""Plugin scaffolder. + +Generates a new plugin skeleton from the templates under +shopdb/plugins/templates/. The generated plugin satisfies the framework +contract out of the box; tests/test_plugin_scaffold.py is the canary +that this stays true as the contract evolves. +""" + +import re +from pathlib import Path +from string import Template +from typing import Optional + + +TEMPLATE_ROOT = Path(__file__).parent / 'templates' + +VALID_NAME_PATTERN = re.compile(r'^[a-z][a-z0-9]*$') + +RESERVED_NAMES = { + 'plugin', 'plugins', 'shopdb', 'core', 'api', 'tests', 'templates', + 'schemas', 'models', 'frontend', 'docs', 'migrations', 'scripts', +} + + +class ScaffoldError(Exception): + """Raised when scaffolding cannot proceed.""" + + +def validate_name(name: str) -> None: + """Validate plugin name against CONTRIBUTING.md and reserved list. + + Raises ScaffoldError on any violation. + """ + if not name: + raise ScaffoldError('Plugin name is required') + + if not VALID_NAME_PATTERN.match(name): + raise ScaffoldError( + f'Plugin name "{name}" must be lowercase letters and digits only, ' + f'starting with a letter (no hyphens, underscores, or special chars). ' + f'See CONTRIBUTING.md for the naming convention.' + ) + + if name in RESERVED_NAMES: + raise ScaffoldError( + f'Plugin name "{name}" is reserved. Pick a different name.' + ) + + +def pascal_case(name: str) -> str: + """Convert lowercase plugin name to PascalCase class name. + + 'cameras' -> 'Cameras'. The convention assumes single-word lowercase + plugin names per the naming rules in CONTRIBUTING.md, so this is + just title-casing. + """ + return name[:1].upper() + name[1:] + + +def _render_template( + template_path: Path, + out_path: Path, + substitutions: dict, + overwrite: bool, +) -> bool: + """Render one template to out_path. + + Skips silently when out_path already exists and overwrite is False, so a + scaffold never clobbers a file the author has already edited. Returns True + when the file was written, False when it was skipped. + """ + if out_path.exists() and not overwrite: + return False + out_path.parent.mkdir(parents=True, exist_ok=True) + body = template_path.read_text() + out_path.write_text(Template(body).safe_substitute(substitutions)) + return True + + +def _scaffold_frontend( + name: str, + substitutions: dict, + plugin_target: Path, + frontend_dir: Path, + template_root: Path, + overwrite: bool, +) -> None: + """Render the frontend starting points for a scaffolded plugin. + + Writes the paste-in api-client snippet into the plugin directory, then the + Vue views and the router route file into the real frontend tree. The + snippet is emitted regardless of whether the frontend tree exists, because + it is a plugin-directory artifact useful even for external-repo plugins. + Views and the route file are skipped when frontend_dir is missing, which is + the normal case for a plugin developed in its own repository. + + Ordering matters: every view is written before the route file. A route file + that lazy-imports a view that is not on disk crashes the Vite dev server, + so the views must land first. + """ + fe_templates = template_root / 'frontend' + if not fe_templates.exists(): + return + + plugin_name = substitutions['Name'] + + # snippet lands in the plugin dir; author pastes it into api/index.js + snippet_template = fe_templates / 'frontend-api-snippet.js.tmpl' + if snippet_template.exists(): + _render_template( + snippet_template, + plugin_target / 'frontend-api-snippet.js', + substitutions, + overwrite, + ) + + # views and route need the real frontend tree; external repos skip these + if not frontend_dir.exists(): + return + + views_dir = frontend_dir / 'views' / name + + # views first: route file lazy-imports them, missing views 500 vite + view_templates = { + 'List.vue.tmpl': f'{plugin_name}List.vue', + 'Detail.vue.tmpl': f'{plugin_name}Detail.vue', + 'Form.vue.tmpl': f'{plugin_name}Form.vue', + } + for template_name, out_name in view_templates.items(): + template_path = fe_templates / 'views' / template_name + if template_path.exists(): + _render_template( + template_path, + views_dir / out_name, + substitutions, + overwrite, + ) + + # route file last: all referenced views now exist on disk + route_template = fe_templates / 'routes.js.tmpl' + if route_template.exists(): + _render_template( + route_template, + frontend_dir / 'router' / 'routes' / f'{name}.js', + substitutions, + overwrite, + ) + + +def scaffold_plugin( + name: str, + description: str, + plugins_dir: Path, + template_root: Optional[Path] = None, + overwrite: bool = False, + frontend: bool = True, + frontend_dir: Optional[Path] = None, +) -> Path: + """Generate a new plugin from templates. + + Args: + name: Plugin name (lowercase, single word) + description: One-sentence description for manifest.json + README + plugins_dir: Target plugins directory (e.g., /plugins) + template_root: Override template source dir (default: bundled templates) + overwrite: If True, overwrite an existing plugin directory and any + existing generated frontend files + frontend: If True, also render the Vue frontend starting points (list, + detail, form views, a route file, and a paste-in api-client snippet) + frontend_dir: Frontend src directory to render views/routes into + (default: /../frontend/src). Views and the route file + are skipped when this directory does not exist, which is the normal + case for a plugin developed in its own repository. + + Returns: + Path to the generated plugin directory. + + Raises: + ScaffoldError on validation failure or when target exists and + overwrite is False. + """ + validate_name(name) + + template_root = template_root or TEMPLATE_ROOT + if not template_root.exists(): + raise ScaffoldError(f'Template root not found: {template_root}') + + target = plugins_dir / name + + if target.exists(): + if not overwrite: + raise ScaffoldError( + f'Plugin directory already exists: {target}. ' + f'Pass overwrite=True or remove it first.' + ) + + substitutions = { + 'name': name, + 'Name': pascal_case(name), + 'description': description, + } + + target.mkdir(parents=True, exist_ok=True) + + for template_path in template_root.rglob('*.tmpl'): + rel = template_path.relative_to(template_root) + + # frontend templates render into the frontend tree, not the plugin dir + if rel.parts and rel.parts[0] == 'frontend': + continue + + out_rel_str = str(rel.with_suffix('')) + if 'model.py' in out_rel_str: + out_rel_str = out_rel_str.replace('model.py', f'{name}.py') + + out_path = target / out_rel_str + out_path.parent.mkdir(parents=True, exist_ok=True) + + body = template_path.read_text() + rendered = Template(body).safe_substitute(substitutions) + + out_path.write_text(rendered) + + if frontend: + if frontend_dir is None: + frontend_dir = plugins_dir.parent / 'frontend' / 'src' + _scaffold_frontend( + name=name, + substitutions=substitutions, + plugin_target=target, + frontend_dir=Path(frontend_dir), + template_root=template_root, + overwrite=overwrite, + ) + + return target diff --git a/shopdb/plugins/templates/README.md.tmpl b/shopdb/plugins/templates/README.md.tmpl new file mode 100644 index 00000000..7b0c439b --- /dev/null +++ b/shopdb/plugins/templates/README.md.tmpl @@ -0,0 +1,43 @@ +# $Name plugin + +$description + +This plugin was generated by `flask plugin new $name`. It satisfies the framework contract out of the box. Replace the example model and routes with your domain. + +## What's here + +- `plugin.py` - the `${Name}Plugin` class extending `BasePlugin`. Edit `init_app` for custom setup, `on_install` to seed reference data. +- `models/$name.py` - example Asset extension table. Replace `examplefield` with your domain fields. +- `api/routes.py` - example list and detail endpoints. Add CRUD as needed. +- `schemas/__init__.py` - marshmallow schema stub for request/response validation. +- `tests/test_plugin.py` - smoke tests asserting contract compliance. +- `manifest.json` - plugin metadata. Bump `version` on changes; keep `core_version` range broad. + +## Common edits + +| You want to... | Do this | +|---|---| +| Add a hook (search, navigation, dashboard widget) | Override the method in `${Name}Plugin`. See the PLUGIN-HOOKS wiki page. | +| Accept external collector data | Override `get_collector_schema()` to return a JSON Schema. See ADR-006. | +| Add another model | Create `models/.py`, export it in `models/__init__.py`, return it in `get_models()`. | +| Add a CLI command | Override `get_cli_commands()` returning a list of Click commands. | + +## Frontend + +Vue components for this plugin live under `frontend/src/views/$name/` (per project convention). Backend scaffolding does not generate frontend yet; copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) as a starting point. + +## Install and run + +```bash +flask plugin install $name +flask db migrate -m "Add $name plugin tables" +flask db upgrade +pytest plugins/$name/tests/ +``` + +## References + +- the PLUGIN-HOOKS wiki page - canonical hook reference +- the PLUGIN-QUICKSTART wiki page - 30-minute walkthrough +- `migrations/adr/ADR-001-asset-as-platform-contract.md` - the platform contract +- `migrations/adr/ADR-002-plugin-versioning.md` - versioning rules diff --git a/shopdb/plugins/templates/__init__.py.tmpl b/shopdb/plugins/templates/__init__.py.tmpl new file mode 100644 index 00000000..61eebb58 --- /dev/null +++ b/shopdb/plugins/templates/__init__.py.tmpl @@ -0,0 +1,5 @@ +"""$Name plugin package.""" + +from .plugin import ${Name}Plugin + +__all__ = ['${Name}Plugin'] diff --git a/shopdb/plugins/templates/api/__init__.py.tmpl b/shopdb/plugins/templates/api/__init__.py.tmpl new file mode 100644 index 00000000..a2e38820 --- /dev/null +++ b/shopdb/plugins/templates/api/__init__.py.tmpl @@ -0,0 +1,5 @@ +"""$Name plugin API package.""" + +from .routes import ${name}_bp + +__all__ = ['${name}_bp'] diff --git a/shopdb/plugins/templates/api/routes.py.tmpl b/shopdb/plugins/templates/api/routes.py.tmpl new file mode 100644 index 00000000..7460022e --- /dev/null +++ b/shopdb/plugins/templates/api/routes.py.tmpl @@ -0,0 +1,45 @@ +"""$Name plugin API routes.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import ( + success_response, + error_response, + paginated_response, + ErrorCodes, + get_pagination_params, + paginate_query, +) + +from ..models import $Name + + +${name}_bp = Blueprint('$name', __name__) + + +@${name}_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_${name}(): + """List $name assets, paginated.""" + page, per_page = get_pagination_params(request) + + query = ${Name}.query + items, total = paginate_query(query, page, per_page) + data = [item.to_dict() for item in items] + + return paginated_response(data, page, per_page, total) + + +@${name}_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_${name}(assetid: int): + """Get a single $name by assetid.""" + item = ${Name}.query.get(assetid) + if not item: + return error_response( + ErrorCodes.NOT_FOUND, + f'$Name with assetid {assetid} not found', + http_code=404, + ) + return success_response(item.to_dict()) diff --git a/shopdb/plugins/templates/frontend/frontend-api-snippet.js.tmpl b/shopdb/plugins/templates/frontend/frontend-api-snippet.js.tmpl new file mode 100644 index 00000000..58776494 --- /dev/null +++ b/shopdb/plugins/templates/frontend/frontend-api-snippet.js.tmpl @@ -0,0 +1,35 @@ +/* + * ${Name} API client snippet. + * + * Paste this ${name}Api block into frontend/src/api/index.js (next to the + * other per-resource blocks). Then, in the generated ${Name}List, ${Name}Detail, + * and ${Name}Form views, delete the local ${name}Api const and import the shared + * one instead: + * + * import { ${name}Api } from '../../api' + * + * The scaffolded views ship with an identical inline client so they build and + * run before you touch the shared api module. This file is NOT auto-merged into + * api/index.js on purpose; that module is hand-maintained and shared. + * + * The create/update/delete calls assume matching POST/PUT/DELETE routes exist + * on the backend. The scaffolded api/routes.py only ships list and get; add the + * write endpoints when you wire up the form. + */ +export const ${name}Api = { + list(params = {}) { + return api.get('/${name}', { params }) + }, + get(itemId) { + return api.get(`/${name}/${itemId}`) + }, + create(data) { + return api.post('/${name}', data) + }, + update(itemId, data) { + return api.put(`/${name}/${itemId}`, data) + }, + remove(itemId) { + return api.delete(`/${name}/${itemId}`) + } +} diff --git a/shopdb/plugins/templates/frontend/routes.js.tmpl b/shopdb/plugins/templates/frontend/routes.js.tmpl new file mode 100644 index 00000000..758b6f70 --- /dev/null +++ b/shopdb/plugins/templates/frontend/routes.js.tmpl @@ -0,0 +1,35 @@ +/** + * ${Name} plugin routes. + * + * Auto-discovered by the router via import.meta.glob, so no registration + * edit is needed. Every route carries meta.plugin '${name}' so the ADR-009 + * guard redirects to the dashboard when the ${name} backend plugin is + * disabled. Form routes add requiresAuth so anonymous users cannot reach + * create or edit. + */ +export default [ + { + path: '${name}', + name: '${name}', + component: () => import('../../views/${name}/${Name}List.vue'), + meta: { plugin: '${name}' } + }, + { + path: '${name}/new', + name: '${name}-new', + component: () => import('../../views/${name}/${Name}Form.vue'), + meta: { requiresAuth: true, plugin: '${name}' } + }, + { + path: '${name}/:id', + name: '${name}-detail', + component: () => import('../../views/${name}/${Name}Detail.vue'), + meta: { plugin: '${name}' } + }, + { + path: '${name}/:id/edit', + name: '${name}-edit', + component: () => import('../../views/${name}/${Name}Form.vue'), + meta: { requiresAuth: true, plugin: '${name}' } + } +] diff --git a/shopdb/plugins/templates/frontend/views/Detail.vue.tmpl b/shopdb/plugins/templates/frontend/views/Detail.vue.tmpl new file mode 100644 index 00000000..9777a799 --- /dev/null +++ b/shopdb/plugins/templates/frontend/views/Detail.vue.tmpl @@ -0,0 +1,149 @@ + + + + + diff --git a/shopdb/plugins/templates/frontend/views/Form.vue.tmpl b/shopdb/plugins/templates/frontend/views/Form.vue.tmpl new file mode 100644 index 00000000..a1a4031b --- /dev/null +++ b/shopdb/plugins/templates/frontend/views/Form.vue.tmpl @@ -0,0 +1,228 @@ + + + + + diff --git a/shopdb/plugins/templates/frontend/views/List.vue.tmpl b/shopdb/plugins/templates/frontend/views/List.vue.tmpl new file mode 100644 index 00000000..bf324aae --- /dev/null +++ b/shopdb/plugins/templates/frontend/views/List.vue.tmpl @@ -0,0 +1,142 @@ + + + + + diff --git a/shopdb/plugins/templates/manifest.json.tmpl b/shopdb/plugins/templates/manifest.json.tmpl new file mode 100644 index 00000000..7c8f9d0c --- /dev/null +++ b/shopdb/plugins/templates/manifest.json.tmpl @@ -0,0 +1,9 @@ +{ + "name": "$name", + "version": "0.1.0", + "description": "$description", + "author": "", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/$name" +} diff --git a/shopdb/plugins/templates/models/__init__.py.tmpl b/shopdb/plugins/templates/models/__init__.py.tmpl new file mode 100644 index 00000000..792db2cd --- /dev/null +++ b/shopdb/plugins/templates/models/__init__.py.tmpl @@ -0,0 +1,5 @@ +"""$Name plugin models.""" + +from .$name import $Name + +__all__ = ['$Name'] diff --git a/shopdb/plugins/templates/models/model.py.tmpl b/shopdb/plugins/templates/models/model.py.tmpl new file mode 100644 index 00000000..5669bafb --- /dev/null +++ b/shopdb/plugins/templates/models/model.py.tmpl @@ -0,0 +1,32 @@ +"""$Name model. + +This is an Asset extension table keyed by assetid. The Asset row holds +the platform fields (assetnumber, name, vendorid, locationid, etc.); +this table holds the $name-specific fields. Replace the example fields +below with your domain model. +""" + +from shopdb.api import db, BaseModel + + +class $Name(BaseModel): + """$Name domain entity, extending Asset by assetid.""" + + __tablename__ = '$name' + + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + primary_key=True, + ) + + # TODO: replace these example fields with your domain fields. + examplefield = db.Column(db.String(255), nullable=True) + + asset = db.relationship('Asset', backref=db.backref('$name', uselist=False)) + + def to_dict(self): + return { + 'assetid': self.assetid, + 'examplefield': self.examplefield, + } diff --git a/shopdb/plugins/templates/plugin.py.tmpl b/shopdb/plugins/templates/plugin.py.tmpl new file mode 100644 index 00000000..2daf77c2 --- /dev/null +++ b/shopdb/plugins/templates/plugin.py.tmpl @@ -0,0 +1,70 @@ +"""$Name plugin main class. + +$description +""" + +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 shopdb.api import db, AssetType + +from .models import $Name +from .api import ${name}_bp + +logger = logging.getLogger(__name__) + + +class ${Name}Plugin(BasePlugin): + """$Name plugin. + + $description + """ + + def __init__(self): + manifest_path = Path(__file__).parent / 'manifest.json' + with open(manifest_path) as f: + self._manifest = json.load(f) + + @property + def meta(self) -> PluginMeta: + return PluginMeta( + name=self._manifest['name'], + version=self._manifest['version'], + description=self._manifest['description'], + author=self._manifest.get('author', ''), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.1.0'), + api_prefix=self._manifest.get('api_prefix'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + return ${name}_bp + + def get_models(self) -> List[Type]: + return [$Name] + + def init_app(self, app: Flask, db_instance) -> None: + logger.info(f'$Name plugin initialized (v{self.meta.version})') + + def on_install(self, app: Flask) -> None: + with app.app_context(): + self._ensure_asset_type() + logger.info('$Name plugin installed') + + def _ensure_asset_type(self) -> None: + existing = AssetType.query.filter_by(assettype='$name').first() + if not existing: + asset_type = AssetType( + assettype='$name', + pluginname='$name', + tablename='$name', + description=self.meta.description, + ) + db.session.add(asset_type) + db.session.commit() + logger.debug('Created asset type: $name') diff --git a/shopdb/plugins/templates/schemas/__init__.py.tmpl b/shopdb/plugins/templates/schemas/__init__.py.tmpl new file mode 100644 index 00000000..da566884 --- /dev/null +++ b/shopdb/plugins/templates/schemas/__init__.py.tmpl @@ -0,0 +1,6 @@ +"""$Name plugin schemas (marshmallow). + +Add schema classes here when you need request/response validation +beyond the simple to_dict() output. The framework wires marshmallow +into the response helpers; see the PLUGIN-HOOKS wiki page for details. +""" diff --git a/shopdb/plugins/templates/tests/__init__.py.tmpl b/shopdb/plugins/templates/tests/__init__.py.tmpl new file mode 100644 index 00000000..e69de29b diff --git a/shopdb/plugins/templates/tests/test_plugin.py.tmpl b/shopdb/plugins/templates/tests/test_plugin.py.tmpl new file mode 100644 index 00000000..295af38c --- /dev/null +++ b/shopdb/plugins/templates/tests/test_plugin.py.tmpl @@ -0,0 +1,30 @@ +"""$Name plugin smoke tests. + +Asserts the plugin loads cleanly and satisfies the framework contract. +Replace and extend with domain tests as you build the plugin out. +""" + +from plugins.$name.plugin import ${Name}Plugin + + +def test_${name}_plugin_meta_is_valid(): + """${Name}Plugin.meta returns a PluginMeta with the expected name.""" + plugin = ${Name}Plugin() + assert plugin.meta.name == '$name' + assert plugin.meta.api_prefix == '/api/$name' + + +def test_${name}_plugin_get_blueprint_returns_blueprint(): + """get_blueprint returns a Flask Blueprint, not None.""" + from flask import Blueprint + plugin = ${Name}Plugin() + assert isinstance(plugin.get_blueprint(), Blueprint) + + +def test_${name}_plugin_get_models_returns_a_model(): + """get_models returns a list with at least one SQLAlchemy model.""" + plugin = ${Name}Plugin() + models = plugin.get_models() + assert len(models) >= 1 + for model in models: + assert hasattr(model, '__tablename__') diff --git a/shopdb/static/images/floorplan-placeholder.svg b/shopdb/static/images/floorplan-placeholder.svg new file mode 100644 index 00000000..e33d54a4 --- /dev/null +++ b/shopdb/static/images/floorplan-placeholder.svg @@ -0,0 +1,11 @@ + + + + + + + + + + Upload your facility floor plan in Settings > Map + diff --git a/shopdb/utils/__init__.py b/shopdb/utils/__init__.py new file mode 100644 index 00000000..93edb233 --- /dev/null +++ b/shopdb/utils/__init__.py @@ -0,0 +1,20 @@ +"""Utility modules.""" + +from .responses import ( + api_response, + success_response, + error_response, + paginated_response, + ErrorCodes +) +from .pagination import get_pagination_params, paginate_query + +__all__ = [ + 'api_response', + 'success_response', + 'error_response', + 'paginated_response', + 'ErrorCodes', + 'get_pagination_params', + 'paginate_query' +] diff --git a/shopdb/utils/apitoken_auth.py b/shopdb/utils/apitoken_auth.py new file mode 100644 index 00000000..99ae203e --- /dev/null +++ b/shopdb/utils/apitoken_auth.py @@ -0,0 +1,153 @@ +"""Personal API token (PAT) authentication shim. + +A request may send `Authorization: Bearer shopdb_pat_...`. This is recognized +BEFORE any JWT decode: a before_request hook validates the PAT (hash lookup, +active, not expired, active owner) and, on success, mints a short internal +request-scoped JWT for the token's user and swaps it into the request's +Authorization header. + +Why mint a JWT instead of only stashing the user on g: every write route in +this app stacks a mandatory @jwt_required() ABOVE @require_permission / +@require_role. That mandatory decorator decodes the Authorization header +itself, so the ONLY way a PAT reaches the whole existing auth+authz stack +(jwt_required, require_permission, require_role, import_mode, current_user, +get_jwt_identity) unchanged is to present a genuine JWT downstream. The minted +token lives only in this request's environ and is never returned to the client. + +Result: a PAT authenticates any route a login JWT would, acting as its owner, +with zero changes to the authz decorators or import-mode helpers. +""" + +from datetime import datetime, timezone + +from flask import g, request +from flask_jwt_extended import create_access_token + +from shopdb.extensions import db +from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX +from shopdb.utils.responses import error_response, ErrorCodes + + +# Only rewrite lastusedat when it is older than this, to avoid a DB write on +# every single request a busy integration makes. +_LASTUSED_THROTTLE_SECONDS = 60 + + +def _utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _extract_pat_secret(): + """Return the PAT secret from the Authorization header, or None.""" + header = request.headers.get('Authorization', '') + parts = header.split() + if len(parts) == 2 and parts[0] == 'Bearer' \ + and parts[1].startswith(TOKEN_SECRET_PREFIX): + return parts[1] + return None + + +def resolve_api_token(secret): + """Validate a PAT secret. Return (token, user) or None. + + Shared validator: hash lookup, active token, unexpired, active owner. The + before_request shim and the collector API (which does not decode JWT) both + call this so the checks live in one place. + """ + from shopdb.core.models import User + + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret), isactive=True).first() + if token is None or token.is_expired: + return None + user = db.session.get(User, token.userid) + if user is None or not user.isactive: + return None + return token, user + + +def touch_apitoken_lastused(token): + """Throttled lastusedat write. Independent commit; nothing else is pending + this early in the request, so it cannot clobber route work.""" + now = _utcnow() + if token.lastusedat is None \ + or (now - token.lastusedat).total_seconds() > _LASTUSED_THROTTLE_SECONDS: + token.lastusedat = now + db.session.commit() + + +def service_token_authorized(scope): + """True when the current request carries a managed token scoped for `scope` + whose owner is active and holds that permission. Accepts X-API-Key or a + Bearer PAT (the before_request shim resolves Bearer into g.apitokenid). + Touches lastusedat on success. + + The single contract-surface entry point for unattended SERVICE tokens + (collector.ingest, geenforce.fetch, ...), so plugins authorize a service + token without reaching into core token internals. Returns False on any + miss; the caller returns its own 401. + """ + from shopdb.core.models import User + + api_key = request.headers.get('X-API-Key') + token = None + if api_key and api_key.startswith(TOKEN_SECRET_PREFIX): + resolved = resolve_api_token(api_key) + token = resolved[0] if resolved else None + else: + tokenid = getattr(g, 'apitokenid', None) + if tokenid is not None: + token = db.session.get(ApiToken, tokenid) + if token is None: + return False + scopelist = token.scopelist + if not scopelist or scope not in scopelist: + return False + user = db.session.get(User, token.userid) + if user is None or not user.isactive or not user.haspermission(scope): + return False + touch_apitoken_lastused(token) + return True + + +def install_apitoken_auth(app): + """Register the before_request PAT shim on the app.""" + + @app.before_request + def _apitoken_before_request(): + secret = _extract_pat_secret() + if secret is None: + return + resolved = resolve_api_token(secret) + if resolved is None: + # The caller clearly meant to use a PAT (shopdb_pat_ prefix) but it + # is unknown, revoked, or expired. Reject with a clear 401 instead + # of letting the JWT decoder emit a confusing 422 on the non-JWT. + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Invalid, revoked, or expired API token', + http_code=401) + token, user = resolved + + # Read claim inputs before the (possible) commit expires the instance. + claims = { + 'username': user.username, + 'roles': [role.rolename for role in user.roles], + } + # A scoped token carries a patscopes claim; authz reads it to grant ONLY + # the listed permissions and to deny role gates + import mode. Unscoped + # tokens carry no such claim and mint exactly as a login JWT would. + scopelist = token.scopelist + if scopelist is not None: + claims['patscopes'] = scopelist + # Expose the token/user for audit and introspection if a handler wants it. + g.apitokenid = token.tokenid + g.apitokenuser = user + + touch_apitoken_lastused(token) + + # Mint a request-scoped JWT for the owner and swap it into the header + # so the whole downstream auth stack authenticates as that user. + access_token = create_access_token( + identity=str(user.userid), additional_claims=claims) + request.environ['HTTP_AUTHORIZATION'] = f'Bearer {access_token}' diff --git a/shopdb/utils/authz.py b/shopdb/utils/authz.py new file mode 100644 index 00000000..a8964d98 --- /dev/null +++ b/shopdb/utils/authz.py @@ -0,0 +1,106 @@ +"""Authorization decorators for role and permission gating. + +Authentication (is the caller logged in?) is handled by Flask-JWT-Extended's +@jwt_required. Authorization (is the caller ALLOWED to do this?) is handled +here. The two are separate concerns; a route needs both on any state-changing +action. + +These decorators call verify_jwt_in_request() themselves, so they work whether +or not a separate @jwt_required is also present. The admin role bypasses every +permission check (see User.haspermission), so an admin never needs individual +permissions granted. + +Usage: + + @assets_bp.route('/', methods=['DELETE']) + @jwt_required() + @require_permission('assets.delete') + def delete_asset(asset_id): + ... +""" + +from functools import wraps + +from flask_jwt_extended import verify_jwt_in_request, current_user, get_jwt + +from shopdb.utils.responses import error_response, ErrorCodes + + +def require_permission(permission_name: str): + """Gate a route behind a single permission. Admin role bypasses. + + A scoped personal API token (patscopes claim present) does NOT bypass: it + grants only the permissions in its scope list, intersected with what the + owner actually holds. See shopdb/utils/apitoken_auth.py. + """ + def decorator(view_func): + @wraps(view_func) + def wrapper(*args, **kwargs): + # verify jwt first so current_user is loaded (idempotent if the + # route also has @jwt_required) + verify_jwt_in_request() + if current_user is None: + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Authentication required', + http_code=401 + ) + patscopes = get_jwt().get('patscopes') + if patscopes is not None: + # Scoped PAT: allowed only when this permission is in the scope + # list AND the owner actually holds it. haspermission still + # returns True for an admin owner (who legitimately holds + # everything), so the scope list is the real limiter - the + # admin bypass is suspended. + allowed = (permission_name in patscopes + and current_user.haspermission(permission_name)) + if not allowed: + return error_response( + ErrorCodes.FORBIDDEN, + 'This API token is not scoped for this action', + http_code=403 + ) + return view_func(*args, **kwargs) + if not current_user.haspermission(permission_name): + return error_response( + ErrorCodes.FORBIDDEN, + 'You do not have permission to perform this action', + http_code=403 + ) + return view_func(*args, **kwargs) + return wrapper + return decorator + + +def require_role(rolename: str): + """Gate a route behind a single role (e.g. 'admin'). + + A scoped personal API token (patscopes claim present) is ALWAYS denied here: + scopes gate individual permissions, not roles, so role-gated admin surfaces + require an unscoped token. See shopdb/utils/apitoken_auth.py. + """ + def decorator(view_func): + @wraps(view_func) + def wrapper(*args, **kwargs): + verify_jwt_in_request() + if current_user is None: + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Authentication required', + http_code=401 + ) + if get_jwt().get('patscopes') is not None: + return error_response( + ErrorCodes.FORBIDDEN, + 'Scoped API tokens cannot access role-gated endpoints', + http_code=403 + ) + if not current_user.hasrole(rolename): + return error_response( + ErrorCodes.FORBIDDEN, + f'{rolename.capitalize()} access required', + http_code=403 + ) + return view_func(*args, **kwargs) + return wrapper + return decorator diff --git a/shopdb/utils/cmmc_usb_db.py b/shopdb/utils/cmmc_usb_db.py new file mode 100644 index 00000000..d2c2ccc0 --- /dev/null +++ b/shopdb/utils/cmmc_usb_db.py @@ -0,0 +1,38 @@ +"""Connection helper for the CMMC USB check-in/out database. + +Credentials are pulled from app config (env-backed, see Config.CMMC_USB_DB_*), +never hardcoded. Used by the USB plugin to track device check-in/out against a +separate MySQL database (cmmc_usb), mirroring the read-only employee-directory +pattern in employee_db.py. Unlike the employee DB this one is read-write, so +callers commit their own writes and close the connection in a finally block. +""" + +import pymysql +from flask import current_app + + +def _setting_or_config(setting_key, config_key): + """Non-secret config: a saved Setting wins, else the env-backed app config. + + Lets the setup wizard edit host/name/user without touching .env, while the + password stays env-only. + """ + from shopdb.core.models import Setting + try: + row = Setting.query.filter_by(key=setting_key).first() + if row and row.value: + return row.value + except Exception: + pass + return current_app.config[config_key] + + +def cmmc_usb_connection(): + """Open a pymysql connection to the cmmc_usb DB.""" + return pymysql.connect( + host=_setting_or_config('cmmc_usb_db_host', 'CMMC_USB_DB_HOST'), + user=_setting_or_config('cmmc_usb_db_user', 'CMMC_USB_DB_USER'), + password=current_app.config['CMMC_USB_DB_PASSWORD'], + database=_setting_or_config('cmmc_usb_db_name', 'CMMC_USB_DB_NAME'), + cursorclass=pymysql.cursors.DictCursor, + ) diff --git a/shopdb/utils/employee_db.py b/shopdb/utils/employee_db.py new file mode 100644 index 00000000..78c789ef --- /dev/null +++ b/shopdb/utils/employee_db.py @@ -0,0 +1,36 @@ +"""Connection helper for the read-only employee directory database. + +Credentials are pulled from app config (env-backed, see Config.EMPLOYEE_DB_*), +never hardcoded. Used by the employee lookup API and the notification +recognition feature. +""" + +import pymysql +from flask import current_app + + +def _setting_or_config(setting_key, config_key): + """Non-secret config: a saved Setting wins, else the env-backed app config. + + Lets the setup wizard edit host/name/user without touching .env, while the + password stays env-only. + """ + from shopdb.core.models import Setting + try: + row = Setting.query.filter_by(key=setting_key).first() + if row and row.value: + return row.value + except Exception: + pass + return current_app.config[config_key] + + +def employee_connection(): + """Open a pymysql connection to the employee directory DB.""" + return pymysql.connect( + host=_setting_or_config('employee_db_host', 'EMPLOYEE_DB_HOST'), + user=_setting_or_config('employee_db_user', 'EMPLOYEE_DB_USER'), + password=current_app.config['EMPLOYEE_DB_PASSWORD'], + database=_setting_or_config('employee_db_name', 'EMPLOYEE_DB_NAME'), + cursorclass=pymysql.cursors.DictCursor, + ) diff --git a/shopdb/utils/import_mode.py b/shopdb/utils/import_mode.py new file mode 100644 index 00000000..221f9e67 --- /dev/null +++ b/shopdb/utils/import_mode.py @@ -0,0 +1,111 @@ +"""Import-mode helpers: let admins replay legacy history through the API. + +A migration script importing the classic ASP shopdb +needs two things the normal API withholds: + + 1. Preserve each row's original createddate/modifieddate instead of stamping + "now" on insert/update. + 2. Backdate event history such as USB checkouts to when they really happened. + +Both are gated. The caller must BOTH be an admin AND send the request header +`X-Import-Mode: true`. Outside import mode every helper here is a no-op, so +wiring a call into a normal create/update path never changes behavior for +regular users. See the IMPORT-API wiki page for the operator manual. +""" + +from datetime import datetime, timezone + +from flask import request +from flask_jwt_extended import verify_jwt_in_request, current_user, get_jwt + + +# Request header a migration client sets to opt a request into import mode. +IMPORT_MODE_HEADER = 'X-Import-Mode' + +# strptime formats accepted for import timestamps, tried in order. Covers the +# ISO 'T' form and the legacy MySQL 'space' form, with and without fractions, +# plus a bare date. +_IMPORT_DATETIME_FORMATS = ( + '%Y-%m-%dT%H:%M:%S.%f', + '%Y-%m-%dT%H:%M:%S', + '%Y-%m-%dT%H:%M', + '%Y-%m-%d %H:%M:%S.%f', + '%Y-%m-%d %H:%M:%S', + '%Y-%m-%d %H:%M', + '%Y-%m-%d', +) + + +def import_mode_active(): + """True when the request is an admin-authenticated import request. + + Needs header `X-Import-Mode: true` AND an admin caller. Safe to call from + any request context: verify_jwt_in_request is optional here so a missing or + bad token yields False instead of raising, and it is idempotent when the + route already ran @jwt_required.""" + header = (request.headers.get(IMPORT_MODE_HEADER) or '').strip().lower() + if header != 'true': + return False + verify_jwt_in_request(optional=True) + user = current_user + if user is None or not user.hasrole('admin'): + return False + # A scoped PAT never gets import mode: import mode is an admin-role + # capability and a scoped token suspends the admin bypass. get_jwt is safe + # here - an admin user means a valid JWT was decoded above. + if get_jwt().get('patscopes') is not None: + return False + return True + + +def parse_import_datetime(value): + """Parse an import timestamp into naive UTC, or None. + + Accepts ISO '2020-01-05T12:00:00', legacy 'YYYY-MM-DD HH:MM:SS', a bare + date, or an already-parsed datetime. A trailing 'Z' is treated as UTC. + Timezone-aware input is converted to UTC then stripped to naive, matching + the repo convention of storing naive-UTC in DB DateTime columns.""" + if value is None: + return None + if isinstance(value, datetime): + parsed = value + else: + text = str(value).strip() + if not text: + return None + if text[-1] in ('Z', 'z'): + text = text[:-1] + parsed = None + for fmt in _IMPORT_DATETIME_FORMATS: + try: + parsed = datetime.strptime(text, fmt) + break + except ValueError: + continue + if parsed is None: + # last resort: let fromisoformat try (handles offsets like +00:00) + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is not None: + parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None) + return parsed + + +def apply_import_timestamps(instance, data): + """Stamp createddate/modifieddate on a row from the payload, in import mode. + + Call this in a create/update path after building the instance and before + commit. It is a no-op unless ALL hold: import mode is active (admin + + header), the payload carries the field, and the model actually has the + column. Explicitly setting modifieddate also suppresses the column's + onupdate=now default on updates, so legacy history survives edits too.""" + if not data or not import_mode_active(): + return + created = parse_import_datetime(data.get('createddate')) + if created is not None and hasattr(instance, 'createddate'): + instance.createddate = created + modified = parse_import_datetime(data.get('modifieddate')) + if modified is not None and hasattr(instance, 'modifieddate'): + instance.modifieddate = modified diff --git a/shopdb/utils/mailer.py b/shopdb/utils/mailer.py new file mode 100644 index 00000000..e6081021 --- /dev/null +++ b/shopdb/utils/mailer.py @@ -0,0 +1,259 @@ +"""Email sending service (stdlib smtplib/ssl/email only). + +Reads SMTP configuration settings-first (via the cached settings map) with an +environment-variable fallback when any SMTP_* env var is present. When email is +disabled or the host is unset the sender is a graceful no-op that logs a warning +and returns False, so an unconfigured site never crashes on a send attempt. + +Public helpers: + send_email(to, subject, html, text=None) -> bool + try_send(to, subject, html, text=None) -> (bool, error_or_None) + send_alert(subject, html, text=None) -> bool + render_email(title, body_html, intro=None) -> (html, text) + render_table_email(title, columns, rows, intro=None) -> (html, text) + +The SMTP password is never logged. +""" + +import os +import re +import smtplib +import ssl +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import formataddr + +from flask import current_app, has_app_context + +# Env var names that back each SMTP setting when settings are blank. +_ENV_MAP = { + 'smtp_host': 'SMTP_HOST', + 'smtp_port': 'SMTP_PORT', + 'smtp_username': 'SMTP_USERNAME', + 'smtp_password': 'SMTP_PASSWORD', + 'smtp_from_address': 'SMTP_FROM_ADDRESS', + 'smtp_from_name': 'SMTP_FROM_NAME', + 'alert_recipients': 'SMTP_ALERT_RECIPIENTS', +} + +# Connect/send timeout in seconds. Keeps a wedged relay from hanging a request. +_SMTP_TIMEOUT = 10 + + +def _log(): + """App logger when in an app context, else a module logger.""" + if has_app_context(): + return current_app.logger + import logging + return logging.getLogger('shopdb.mailer') + + +def _env_active(): + """True when the deployment supplies SMTP_* env overrides.""" + return any(k.startswith('SMTP_') for k in os.environ) + + +def get_smtp_config(): + """Resolve the SMTP config settings-first with env fallback. + + Returns a dict with typed fields. `enabled` is False when the site has not + turned email on; callers should treat that as a no-op signal. + """ + settings = {} + if has_app_context(): + # Local import avoids a circular import at module load. + from shopdb.core.api.settings import get_cached_settings + try: + settings = get_cached_settings() or {} + except Exception: + settings = {} + + env_active = _env_active() + + def pick(key, default=''): + val = settings.get(key) + if (val is None or val == '') and env_active: + val = os.environ.get(_ENV_MAP.get(key, ''), default) + return default if val is None else val + + enabled = bool(settings.get('smtp_enabled')) + if not enabled and env_active: + enabled = os.environ.get('SMTP_ENABLED', '').lower() in ('true', '1', 'yes') + + use_tls = settings.get('smtp_use_tls') + if use_tls is None: + if env_active: + use_tls = os.environ.get('SMTP_USE_TLS', 'true').lower() in ('true', '1', 'yes') + else: + use_tls = True + + try: + port = int(pick('smtp_port', 587) or 587) + except (ValueError, TypeError): + port = 587 + + return { + 'enabled': enabled, + 'host': pick('smtp_host'), + 'port': port, + 'username': pick('smtp_username'), + 'password': pick('smtp_password'), + 'use_tls': bool(use_tls), + 'from_address': pick('smtp_from_address'), + 'from_name': pick('smtp_from_name') or 'ShopDB', + 'alert_recipients': pick('alert_recipients'), + } + + +def _normalize_recipients(to): + """Coerce a recipient spec (string, comma/semicolon list, or iterable) to a + clean list of addresses.""" + if not to: + return [] + if isinstance(to, str): + parts = re.split(r'[,;]', to) + else: + parts = list(to) + return [p.strip() for p in parts if p and p.strip()] + + +def try_send(to, subject, html, text=None): + """Send an email. Returns (ok, error). + + ok is False with error=None when email is not configured (a graceful + no-op). ok is False with an error string when a real send failed. The SMTP + password is never included in the error. + """ + config = get_smtp_config() + recipients = _normalize_recipients(to) + + if not config['enabled'] or not config['host']: + _log().warning('Email not sent: SMTP is disabled or host is unset.') + return False, None + if not recipients: + _log().warning('Email not sent: no recipients.') + return False, 'No recipients specified' + if not config['from_address']: + _log().warning('Email not sent: from address is unset.') + return False, 'From address is not configured' + + message = MIMEMultipart('alternative') + message['Subject'] = subject + message['From'] = formataddr((config['from_name'], config['from_address'])) + message['To'] = ', '.join(recipients) + # Plaintext first so alternative-aware clients prefer the HTML part. + message.attach(MIMEText(text or _html_to_text(html), 'plain', 'utf-8')) + message.attach(MIMEText(html, 'html', 'utf-8')) + + try: + context = ssl.create_default_context() + if config['port'] == 465: + server = smtplib.SMTP_SSL( + config['host'], config['port'], + timeout=_SMTP_TIMEOUT, context=context) + else: + server = smtplib.SMTP( + config['host'], config['port'], timeout=_SMTP_TIMEOUT) + with server: + if config['port'] != 465 and config['use_tls']: + server.starttls(context=context) + if config['username']: + server.login(config['username'], config['password']) + server.sendmail(config['from_address'], recipients, message.as_string()) + _log().info('Email sent to %d recipient(s): %s', len(recipients), subject) + return True, None + except Exception as exception: + # Never let the password reach the log or the caller. + error = _scrub(str(exception), config['password']) + _log().error('Email send failed: %s', error) + return False, error + + +def send_email(to, subject, html, text=None): + """Send an email. Returns True on success, False otherwise (no-op safe).""" + ok, _error = try_send(to, subject, html, text=text) + return ok + + +def send_alert(subject, html, text=None): + """Send an alert to the site's configured alert_recipients. Returns False + when email is off or no alert recipients are configured.""" + config = get_smtp_config() + recipients = _normalize_recipients(config['alert_recipients']) + if not recipients: + _log().warning('Alert not sent: no alert_recipients configured.') + return False + return send_email(recipients, subject, html, text=text) + + +def _scrub(value, secret): + """Remove a secret substring from a string (defensive log hygiene).""" + if secret and secret in value: + return value.replace(secret, '***') + return value + + +def _html_to_text(html): + """Very small HTML-to-text fallback for the plaintext alternative.""" + text = re.sub(r'(?i)', '\n', html) + text = re.sub(r'(?i)', '\n', text) + text = re.sub(r'<[^>]+>', '', text) + text = re.sub(r'\n{3,}', '\n\n', text) + return text.strip() + + +def _escape(value): + """HTML-escape a cell value.""" + return (str('' if value is None else value) + .replace('&', '&').replace('<', '<').replace('>', '>')) + + +def render_email(title, body_html, intro=None): + """Wrap body HTML in a simple branded shell. Returns (html, text).""" + intro_html = f'

{_escape(intro)}

' if intro else '' + html = ( + '
' + f'

{_escape(title)}

' + f'{intro_html}{body_html}' + '
' + '

Sent by ShopDB.

' + '
' + ) + return html, _html_to_text(html) + + +def render_table_email(title, columns, rows, intro=None): + """Render tabular report data as an HTML table email. Returns (html, text). + + columns: list of {'key','label'} dicts or of plain strings. + rows: list of dicts keyed by the column keys. + """ + normalized = [] + for column in columns or []: + if isinstance(column, dict): + normalized.append((column.get('key'), column.get('label', column.get('key')))) + else: + normalized.append((column, column)) + + header_cells = ''.join( + f'{_escape(label)}' + for _key, label in normalized) + + body_rows = [] + for row in rows or []: + cells = ''.join( + f'' + f'{_escape(row.get(key) if isinstance(row, dict) else row)}' + for key, _label in normalized) + body_rows.append(f'{cells}') + + table = ( + '' + f'{header_cells}' + f'{"".join(body_rows) or ""}' + '
No data
' + ) + count_line = f'

{len(rows or [])} row(s).

' + return render_email(title, table + count_line, intro=intro) diff --git a/shopdb/utils/pagination.py b/shopdb/utils/pagination.py new file mode 100644 index 00000000..09bdf957 --- /dev/null +++ b/shopdb/utils/pagination.py @@ -0,0 +1,43 @@ +"""Pagination utilities.""" + +from flask import request, current_app +from typing import Tuple + + +def get_pagination_params(request_obj=None) -> Tuple[int, int]: + """ + Extract pagination parameters from request. + + Returns: + Tuple of (page, per_page) + """ + if request_obj is None: + request_obj = request + + default_size = current_app.config.get('DEFAULT_PAGE_SIZE', 20) + max_size = current_app.config.get('MAX_PAGE_SIZE', 100) + + try: + page = max(1, int(request_obj.args.get('page', 1))) + except (TypeError, ValueError): + page = 1 + + try: + per_page = int(request_obj.args.get('perpage', request_obj.args.get('per_page', default_size))) + per_page = max(1, min(per_page, max_size)) + except (TypeError, ValueError): + per_page = default_size + + return page, per_page + + +def paginate_query(query, page: int, per_page: int): + """ + Apply pagination to a SQLAlchemy query. + + Returns: + Tuple of (items, total) + """ + total = query.count() + items = query.offset((page - 1) * per_page).limit(per_page).all() + return items, total diff --git a/shopdb/utils/responses.py b/shopdb/utils/responses.py new file mode 100644 index 00000000..4b7d714c --- /dev/null +++ b/shopdb/utils/responses.py @@ -0,0 +1,175 @@ +"""Standardized API response helpers.""" + +from flask import jsonify, make_response +from typing import Any, Dict, List +from datetime import datetime, timezone +import uuid + + +class ErrorCodes: + """Standard error codes.""" + + VALIDATION_ERROR = 'VALIDATION_ERROR' + NOT_FOUND = 'NOT_FOUND' + UNAUTHORIZED = 'UNAUTHORIZED' + FORBIDDEN = 'FORBIDDEN' + CONFLICT = 'CONFLICT' + INTERNAL_ERROR = 'INTERNAL_ERROR' + BAD_REQUEST = 'BAD_REQUEST' + PLUGIN_ERROR = 'PLUGIN_ERROR' + + +def api_response( + data: Any = None, + message: str = None, + status: str = 'success', + meta: Dict = None, + http_code: int = 200 +): + """ + Create standardized API response. + + Response format: + { + "status": "success" | "error", + "data": {...} | [...], + "message": "Optional message", + "meta": { + "timestamp": "2025-01-12T...", + "request_id": "uuid" + } + } + """ + response = { + 'status': status, + 'meta': { + 'timestamp': datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z', + 'requestid': str(uuid.uuid4())[:8], + **(meta or {}) + } + } + + if data is not None: + response['data'] = data + + if message: + response['message'] = message + + return make_response(jsonify(response), http_code) + + +def success_response( + data: Any = None, + message: str = None, + meta: Dict = None, + http_code: int = 200 +): + """Success response helper.""" + return api_response( + data=data, + message=message, + meta=meta, + status='success', + http_code=http_code + ) + + +def error_response( + code: str, + message: str, + details: Dict = None, + http_code: int = 400 +): + """Error response helper. + + Response format: + { + "status": "error", + "data": { + "error": { + "code": "VALIDATION_ERROR", + "message": "Human-readable message", + "details": {...} + } + }, + "meta": { + "timestamp": "...", + "requestid": "..." + } + } + """ + error_data = { + 'code': code, + 'message': message + } + if details: + error_data['details'] = details + + return api_response( + data={'error': error_data}, + status='error', + http_code=http_code + ) + + +def api_error( + message: str, + code: str = ErrorCodes.BAD_REQUEST, + details: Dict = None, + http_code: int = 400 +): + """ + Simplified error response helper. + + Args: + message: Human-readable error message + code: Error code (default BAD_REQUEST) + details: Optional error details + http_code: HTTP status code (default 400) + """ + return error_response(code=code, message=message, details=details, http_code=http_code) + + +def paginated_response( + items: List, + page: int, + per_page: int, + total: int, + schema=None +): + """Paginated list response. + + Response format: + { + "status": "success", + "data": [...], + "meta": { + "pagination": { + "page": 1, + "perpage": 20, + "total": 150, + "totalpages": 8, + "hasnext": true, + "hasprev": false + } + } + } + """ + total_pages = (total + per_page - 1) // per_page if per_page > 0 else 0 + + if schema: + items = schema.dump(items, many=True) + + return api_response( + data=items, + meta={ + 'pagination': { + 'page': page, + 'perpage': per_page, + 'total': total, + 'totalpages': total_pages, + 'hasnext': page < total_pages, + 'hasprev': page > 1 + } + } + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..46816ddf --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..d6555209 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,148 @@ +"""Pytest configuration and fixtures for shopdb-flask. + +Strategy: in-memory SQLite via StaticPool (configured in TestingConfig) +so the database is shared across the connection. Each test drops and +recreates the schema. Simple, totally isolated, fast enough for a small +schema. Switch to savepoint-per-test if test count grows past a few +hundred. +""" + +import json +import os +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from werkzeug.security import generate_password_hash + +# Force testing config before any shopdb import touches the env. +os.environ['FLASK_ENV'] = 'testing' + +from shopdb import create_app +from shopdb.extensions import db as _db + + +def _bootstrap_plugin_registry(): + # Fresh clones (CI) have no instance/plugins.json - the registry is + # gitignored dev state - so no plugins would load and every plugin-backed + # test fails. Seed a registry enabling all bundled plugins. An existing + # dev registry is left untouched. + repo = Path(__file__).resolve().parent.parent + state_file = repo / 'instance' / 'plugins.json' + if state_file.exists(): + return + stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + plugins = {} + for manifest_path in sorted((repo / 'plugins').glob('*/manifest.json')): + manifest = json.loads(manifest_path.read_text()) + plugins[manifest['name']] = { + 'name': manifest['name'], + 'version': manifest.get('version', '1.0.0'), + 'installed_at': stamp, + 'enabled': True, + 'migrations_applied': [], + 'config': {}, + } + state_file.parent.mkdir(parents=True, exist_ok=True) + state_file.write_text(json.dumps({'plugins': plugins}, indent=2)) + + +_bootstrap_plugin_registry() + + +@pytest.fixture(scope='session') +def app(): + """Create the Flask application for the test session.""" + application = create_app('testing') + return application + + +@pytest.fixture(scope='function') +def db(app): + """Provide a fresh database per test. Drops and recreates schema each run.""" + with app.app_context(): + _db.create_all() + yield _db + _db.session.remove() + _db.drop_all() + + +@pytest.fixture +def client(app): + """Flask test client.""" + return app.test_client() + + +@pytest.fixture +def runner(app): + """Flask CLI test runner.""" + return app.test_cli_runner() + + +@pytest.fixture +def admin_user(db): + """Create an admin user for authenticated tests. + + The user has username 'testadmin' and password 'testpass'. + """ + from shopdb.core.models import User, Role + + role = Role(rolename='admin', description='Administrator') + db.session.add(role) + db.session.flush() + + user = User( + username='testadmin', + email='admin@test.local', + passwordhash=generate_password_hash('testpass'), + ) + user.roles.append(role) + db.session.add(user) + db.session.commit() + + return user + + +@pytest.fixture +def auth_headers(client, admin_user): + """Log in as admin_user and return Authorization headers.""" + response = client.post( + '/api/auth/login', + json={'username': 'testadmin', 'password': 'testpass'}, + ) + assert response.status_code == 200, f'Login failed: {response.get_json()}' + payload = response.get_json() + token = payload['data']['access_token'] + return {'Authorization': f'Bearer {token}'} + + +@pytest.fixture +def member_user(db): + """Create an authenticated user with NO roles/permissions. + + Username 'testmember', password 'testpass'. Used to prove that + authentication alone does not grant write access (authorization gating). + """ + from shopdb.core.models import User + + user = User( + username='testmember', + email='member@test.local', + passwordhash=generate_password_hash('testpass'), + ) + db.session.add(user) + db.session.commit() + + return user + + +@pytest.fixture +def member_headers(client, member_user): + """Log in as member_user (no roles) and return Authorization headers.""" + response = client.post( + '/api/auth/login', + json={'username': 'testmember', 'password': 'testpass'}, + ) + assert response.status_code == 200, f'Login failed: {response.get_json()}' + token = response.get_json()['data']['access_token'] + return {'Authorization': f'Bearer {token}'} diff --git a/tests/test_api_namespace.py b/tests/test_api_namespace.py new file mode 100644 index 00000000..335410ac --- /dev/null +++ b/tests/test_api_namespace.py @@ -0,0 +1,270 @@ +"""Tests for the public shopdb.api namespace exposed to plugins. + +Pins audit_log, resolve_asset_position, and the BasePlugin +get_setting/set_setting helpers as part of the contract surface. +""" + +import pytest + +from shopdb.api import audit_log, resolve_asset_position +from shopdb.core.models import AuditLog, Setting + + +def test_audit_log_creates_row(db, client): + """audit_log writes an AuditLog row with the standard fields.""" + with client.application.test_request_context('/'): + entry = audit_log( + action='created', + entitytype='Computer', + entityid=42, + entityname='PC-1234', + changes={'before': {}, 'after': {'hostname': 'PC-1234'}}, + ) + + assert entry is not None + assert entry.action == 'created' + assert entry.entitytype == 'Computer' + assert entry.entityid == 42 + assert entry.entityname == 'PC-1234' + + saved = AuditLog.query.filter_by(entityid=42, entitytype='Computer').first() + assert saved is not None + + +def test_resolve_asset_position_returns_none_when_no_data(): + """An asset with no coords and no location returns None.""" + class FakeAsset: + mapx = None + mapy = None + location = None + + assert resolve_asset_position(FakeAsset()) is None + + +def test_resolve_asset_position_uses_self_when_set(): + """Asset-specific coords win over everything else.""" + class FakeLocation: + mapx = 10 + mapy = 20 + + class FakeAsset: + mapx = 100 + mapy = 200 + location = FakeLocation() + + result = resolve_asset_position(FakeAsset()) + assert result == {'mapx': 100, 'mapy': 200, 'positionsource': 'self'} + + +def test_resolve_asset_position_falls_back_to_location(): + """When asset has no coords, falls back to location coords.""" + class FakeLocation: + mapx = 50 + mapy = 75 + + class FakeAsset: + mapx = None + mapy = None + location = FakeLocation() + + result = resolve_asset_position(FakeAsset()) + assert result == {'mapx': 50, 'mapy': 75, 'positionsource': 'location'} + + +def test_resolve_asset_position_handles_asset_without_mapx_attr(): + """Assets that don't yet have mapx/mapy columns degrade gracefully.""" + class FakeLocation: + mapx = 1 + mapy = 2 + + class FakeAsset: + location = FakeLocation() + + result = resolve_asset_position(FakeAsset()) + assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'location'} + + +# --- Relationship-walk path (priority 2 in the chain) ---------------------- + +def _make_rel(rtype_name, neighbor, inheritsposition=True, isactive=True): + """Build a fake AssetRelationship-shaped object pointing at neighbor. + `neighbor` is wired as both source and target so the walk helper finds + it regardless of direction; tests pick which list to attach it to.""" + class FakeRelType: + relationshiptype = rtype_name + + class FakeRel: + pass + + r = FakeRel() + r.relationshiptype = FakeRelType() + r.inheritsposition = inheritsposition + r.isactive = isactive + r.targetasset = neighbor + r.sourceasset = neighbor + return r + + +def _make_asset(assetid, mapx=None, mapy=None, outgoing=None, incoming=None, location=None): + class FakeAsset: + pass + + a = FakeAsset() + a.assetid = assetid + a.mapx = mapx + a.mapy = mapy + a.outgoing_relationships = outgoing or [] + a.incoming_relationships = incoming or [] + a.location = location + return a + + +def test_resolve_asset_position_walks_partof_edge(): + """Priority 2: inheritsposition=true partof edge resolves from neighbor.""" + parent = _make_asset(assetid=2, mapx=300, mapy=400) + rel = _make_rel('partof', parent) + child = _make_asset(assetid=1, outgoing=[rel]) + + result = resolve_asset_position(child) + assert result == {'mapx': 300, 'mapy': 400, 'positionsource': 'related'} + + +def test_resolve_asset_position_walks_controls_after_partof(): + """Priority 2 ordering: partof beats controls when both have coords.""" + partof_neighbor = _make_asset(assetid=10, mapx=11, mapy=12) + controls_neighbor = _make_asset(assetid=20, mapx=99, mapy=99) + rel_partof = _make_rel('partof', partof_neighbor) + rel_controls = _make_rel('controls', controls_neighbor) + asset = _make_asset(assetid=1, outgoing=[rel_controls, rel_partof]) + + result = resolve_asset_position(asset) + assert result == {'mapx': 11, 'mapy': 12, 'positionsource': 'related'} + + +def test_resolve_asset_position_skips_non_inheritable_type(): + """connectedto edges are never walked even if inheritsposition is true.""" + neighbor = _make_asset(assetid=2, mapx=5, mapy=6) + rel = _make_rel('connectedto', neighbor, inheritsposition=True) + asset = _make_asset(assetid=1, outgoing=[rel]) + + assert resolve_asset_position(asset) is None + + +def test_resolve_asset_position_skips_when_inheritsposition_false(): + """An edge with inheritsposition=false is not walked.""" + neighbor = _make_asset(assetid=2, mapx=5, mapy=6) + rel = _make_rel('partof', neighbor, inheritsposition=False) + asset = _make_asset(assetid=1, outgoing=[rel]) + + assert resolve_asset_position(asset) is None + + +def test_resolve_asset_position_walks_recursively(): + """The walk recurses: child -> middle -> root, where only root has coords.""" + root = _make_asset(assetid=3, mapx=1, mapy=2) + middle = _make_asset(assetid=2, outgoing=[_make_rel('partof', root)]) + child = _make_asset(assetid=1, outgoing=[_make_rel('partof', middle)]) + + result = resolve_asset_position(child) + assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'related'} + + +def test_resolve_asset_position_breaks_cycles(): + """A cycle A<->B with no coords anywhere returns None without recursing + forever.""" + a = _make_asset(assetid=1) + b = _make_asset(assetid=2) + a.outgoing_relationships = [_make_rel('partof', b)] + b.outgoing_relationships = [_make_rel('partof', a)] + + assert resolve_asset_position(a) is None + + +def test_resolve_asset_position_depth_cap_is_three(): + """Past depth 3 the walk gives up. Build a chain of 5 nodes where only + the last has coords; expect None.""" + coords_node = _make_asset(assetid=5, mapx=99, mapy=99) + n4 = _make_asset(assetid=4, outgoing=[_make_rel('partof', coords_node)]) + n3 = _make_asset(assetid=3, outgoing=[_make_rel('partof', n4)]) + n2 = _make_asset(assetid=2, outgoing=[_make_rel('partof', n3)]) + root = _make_asset(assetid=1, outgoing=[_make_rel('partof', n2)]) + + assert resolve_asset_position(root) is None + + +def test_resolve_asset_position_self_beats_related(): + """Priority 1 beats priority 2: asset's own coords win even when a + related neighbor would also resolve.""" + neighbor = _make_asset(assetid=2, mapx=99, mapy=99) + rel = _make_rel('partof', neighbor) + asset = _make_asset(assetid=1, mapx=1, mapy=2, outgoing=[rel]) + + result = resolve_asset_position(asset) + assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'self'} + + +def test_resolve_asset_position_related_beats_location(): + """Priority 2 beats priority 3: a related neighbor's coords win over + the asset's location coords.""" + class FakeLocation: + mapx = 500 + mapy = 600 + + neighbor = _make_asset(assetid=2, mapx=10, mapy=20) + rel = _make_rel('partof', neighbor) + asset = _make_asset(assetid=1, outgoing=[rel], location=FakeLocation()) + + result = resolve_asset_position(asset) + assert result == {'mapx': 10, 'mapy': 20, 'positionsource': 'related'} + + +def test_resolve_asset_position_inactive_edge_skipped(): + """Soft-deleted (isactive=false) relationships are not walked.""" + neighbor = _make_asset(assetid=2, mapx=5, mapy=6) + rel = _make_rel('partof', neighbor, isactive=False) + asset = _make_asset(assetid=1, outgoing=[rel]) + + assert resolve_asset_position(asset) is None + + +def test_plugin_get_setting_returns_default_when_unset(db, app): + """A plugin reading an unset setting gets the default.""" + from shopdb.plugins import plugin_manager + with app.app_context(): + printers = plugin_manager.get_plugin('printers') + if printers is None: + pytest.skip('printers plugin not loaded') + assert printers.get_setting('nonexistentkey', default='fallback') == 'fallback' + + +def test_plugin_set_and_get_setting_roundtrip(db, app): + """A plugin can set and read its own setting; key is namespaced.""" + from shopdb.plugins import plugin_manager + with app.app_context(): + printers = plugin_manager.get_plugin('printers') + if printers is None: + pytest.skip('printers plugin not loaded') + + printers.set_setting('zabbix_url', 'http://zabbix.example.com') + + assert printers.get_setting('zabbix_url') == 'http://zabbix.example.com' + + raw = Setting.query.filter_by(key='plugin.printers.zabbix_url').first() + assert raw is not None + assert raw.value == 'http://zabbix.example.com' + + +def test_plugin_setting_is_namespaced_per_plugin(db, app): + """Two plugins using the same key do not collide.""" + from shopdb.plugins import plugin_manager + with app.app_context(): + printers = plugin_manager.get_plugin('printers') + computers = plugin_manager.get_plugin('computers') + if printers is None or computers is None: + pytest.skip('required plugins not loaded') + + printers.set_setting('shared_key', 'printers_value') + computers.set_setting('shared_key', 'computers_value') + + assert printers.get_setting('shared_key') == 'printers_value' + assert computers.get_setting('shared_key') == 'computers_value' diff --git a/tests/test_core/__init__.py b/tests/test_core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_core/test_apitokens.py b/tests/test_core/test_apitokens.py new file mode 100644 index 00000000..1b0bdaef --- /dev/null +++ b/tests/test_core/test_apitokens.py @@ -0,0 +1,419 @@ +"""Personal API token tests. + +Covers: create returns the secret once and stores only a hash; a PAT +authenticates a permission-gated write as its owner; a PAT is rejected when its +owner lacks the permission; expired and revoked tokens are rejected; lastusedat +updates on use; a non-owner member cannot revoke someone else's token; an admin +lists everyone's tokens with ?all=true; and import mode works over a PAT for an +admin. +""" + +from datetime import datetime, timedelta, timezone + +from werkzeug.security import generate_password_hash + +from shopdb.core.models import ApiToken, Application, Vendor +from shopdb.extensions import db as _db + + +def _naive_utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _create_token(client, headers, name='test token', expiresat=None, scopes=None): + body = {'name': name} + if expiresat is not None: + body['expiresat'] = expiresat + if scopes is not None: + body['scopes'] = scopes + response = client.post('/api/apitokens', json=body, headers=headers) + return response + + +def _pat_headers(secret): + return {'Authorization': f'Bearer {secret}'} + + +def _user_with_perms(client, db, username, permnames): + """Create a non-admin user holding permnames, return (user, login headers). + + Seeds the catalog so the named Permission rows exist, links them to a fresh + role, and logs the user in. Used to prove the apitokens.create gate and the + owner scope ceiling for non-admin owners. + """ + from shopdb.core.models import User, Role, Permission + Permission.seed() + role = Role(rolename=username + 'role') + db.session.add(role) + db.session.flush() + role.permissions = Permission.query.filter( + Permission.name.in_(permnames)).all() + user = User(username=username, email=username + '@test.local', + passwordhash=generate_password_hash('testpass')) + user.roles.append(role) + db.session.add(user) + db.session.commit() + login = client.post('/api/auth/login', + json={'username': username, 'password': 'testpass'}) + headers = {'Authorization': f"Bearer {login.get_json()['data']['access_token']}"} + return user, headers + + +def test_create_returns_secret_once_and_stores_hash(client, db, auth_headers): + response = _create_token(client, auth_headers, name='import runner') + assert response.status_code == 201 + data = response.get_json()['data'] + + secret = data['secret'] + assert secret.startswith('shopdb_pat_') + assert 'warning' in data + # The stored row must not carry the raw secret; only its hash. + token = ApiToken.query.filter_by(tokenid=data['tokenid']).first() + assert token is not None + assert token.tokenhash == ApiToken.hash_secret(secret) + assert secret not in (token.tokenhash, token.tokenprefix or '') + assert token.tokenprefix and token.tokenprefix in secret + + +def test_pat_authenticates_permission_write_as_owner(client, db, admin_user, + auth_headers): + """A PAT owned by an admin can create a vendor (admin-gated write).""" + secret = _create_token(client, auth_headers).get_json()['data']['secret'] + + response = client.post('/api/vendors', json={'vendor': 'PAT Vendor'}, + headers=_pat_headers(secret)) + assert response.status_code == 201 + assert Vendor.query.filter_by(vendor='PAT Vendor').first() is not None + + +def test_pat_403_when_owner_lacks_permission(client, db): + """A PAT owned by a non-admin (who can mint tokens but is not admin) is + forbidden from an admin-role write.""" + _user, headers = _user_with_perms(client, db, 'tokenmaker', + ['apitokens.create']) + secret = _create_token(client, headers).get_json()['data']['secret'] + + response = client.post('/api/vendors', json={'vendor': 'Nope'}, + headers=_pat_headers(secret)) + assert response.status_code == 403 + assert Vendor.query.filter_by(vendor='Nope').first() is None + + +def test_expired_token_rejected(client, db, admin_user, auth_headers): + secret = _create_token(client, auth_headers).get_json()['data']['secret'] + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first() + token.expiresat = _naive_utcnow() - timedelta(days=1) + _db.session.commit() + + response = client.post('/api/vendors', json={'vendor': 'Expired'}, + headers=_pat_headers(secret)) + assert response.status_code == 401 + assert Vendor.query.filter_by(vendor='Expired').first() is None + + +def test_revoked_token_rejected(client, db, admin_user, auth_headers): + secret = _create_token(client, auth_headers).get_json()['data']['secret'] + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first() + tokenid = token.tokenid + + revoke = client.delete(f'/api/apitokens/{tokenid}', headers=auth_headers) + assert revoke.status_code == 200 + + response = client.post('/api/vendors', json={'vendor': 'Revoked'}, + headers=_pat_headers(secret)) + assert response.status_code == 401 + assert Vendor.query.filter_by(vendor='Revoked').first() is None + + +def test_lastusedat_updates_on_use(client, db, admin_user, auth_headers): + secret = _create_token(client, auth_headers).get_json()['data']['secret'] + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first() + assert token.lastusedat is None + + client.get('/api/apitokens', headers=_pat_headers(secret)) + _db.session.expire_all() + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first() + assert token.lastusedat is not None + + +def test_member_cannot_revoke_other_users_token(client, db, admin_user, + auth_headers): + """A token-capable non-admin cannot revoke a token owned by someone else.""" + secret = _create_token(client, auth_headers).get_json()['data']['secret'] + tokenid = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first().tokenid + + _user, headers = _user_with_perms(client, db, 'tokenmaker', + ['apitokens.create']) + response = client.delete(f'/api/apitokens/{tokenid}', headers=headers) + assert response.status_code == 403 + # Still active. + assert _db.session.get(ApiToken, tokenid).isactive is True + + +def test_roleless_member_cannot_create_token(client, db, member_user, + member_headers): + """Creating a token now requires apitokens.create; a role-less member is + denied with 403.""" + response = _create_token(client, member_headers, name='nope') + assert response.status_code == 403 + + +def test_member_with_permission_can_manage_own_token(client, db): + """A non-admin granted apitokens.create manages their OWN tokens.""" + _user, headers = _user_with_perms(client, db, 'tokenmaker', + ['apitokens.create']) + create = _create_token(client, headers, name='mine') + assert create.status_code == 201 + tokenid = create.get_json()['data']['tokenid'] + + revoke = client.delete(f'/api/apitokens/{tokenid}', headers=headers) + assert revoke.status_code == 200 + assert _db.session.get(ApiToken, tokenid).isactive is False + + +def test_admin_all_true_lists_everyone(client, db, admin_user, auth_headers): + _create_token(client, auth_headers, name='admin token') + member, member_headers = _user_with_perms(client, db, 'tokenmaker', + ['apitokens.create']) + _create_token(client, member_headers, name='member token') + + # Own-only (default) for admin: just the admin's token. + own = client.get('/api/apitokens', headers=auth_headers).get_json()['data'] + assert all(t['userid'] == admin_user.userid for t in own) + + # all=true: both users' tokens, with owner usernames. + everyone = client.get('/api/apitokens?all=true', + headers=auth_headers).get_json()['data'] + userids = {t['userid'] for t in everyone} + assert admin_user.userid in userids and member.userid in userids + assert any(t.get('username') for t in everyone) + + +def test_member_all_true_ignored(client, db, admin_user, auth_headers): + """A non-admin passing ?all=true still only sees their own tokens.""" + _create_token(client, auth_headers, name='admin token') + member, member_headers = _user_with_perms(client, db, 'tokenmaker', + ['apitokens.create']) + _create_token(client, member_headers, name='member token') + + result = client.get('/api/apitokens?all=true', + headers=member_headers).get_json()['data'] + assert all(t['userid'] == member.userid for t in result) + + +def test_import_mode_works_over_pat(client, db, admin_user, auth_headers): + """An admin PAT plus X-Import-Mode backdates createddate on a write.""" + secret = _create_token(client, auth_headers).get_json()['data']['secret'] + + headers = _pat_headers(secret) + headers['X-Import-Mode'] = 'true' + response = client.post( + '/api/vendors', + json={'vendor': 'Legacy Vendor', 'createddate': '2019-01-02 03:04:05'}, + headers=headers) + assert response.status_code == 201 + + vendor = Vendor.query.filter_by(vendor='Legacy Vendor').first() + assert vendor is not None + assert vendor.createddate == datetime(2019, 1, 2, 3, 4, 5) + + +# --- Scoped tokens ------------------------------------------------------- + +def test_scoped_token_passes_listed_permission(client, db, admin_user, + auth_headers): + """A token scoped to applications.create can create an application.""" + secret = _create_token(client, auth_headers, + scopes=['applications.create']).get_json()['data']['secret'] + + response = client.post('/api/applications', json={'appname': 'Scoped App'}, + headers=_pat_headers(secret)) + assert response.status_code == 201 + assert Application.query.filter_by(appname='Scoped App').first() is not None + + +def test_scoped_token_403_on_unlisted_permission(client, db, admin_user, + auth_headers): + """A token scoped to only applications.create cannot delete (needs + applications.delete, which is not in scope).""" + app = Application(appname='Target App') + _db.session.add(app) + _db.session.commit() + appid = app.appid + + secret = _create_token(client, auth_headers, + scopes=['applications.create']).get_json()['data']['secret'] + response = client.delete(f'/api/applications/{appid}', + headers=_pat_headers(secret)) + assert response.status_code == 403 + assert _db.session.get(Application, appid) is not None + + +def test_scoped_admin_token_does_not_bypass(client, db, admin_user, auth_headers): + """An admin owner's scoped token is genuinely limited: the admin permission + bypass is suspended, so an unlisted action is 403 even for an admin.""" + secret = _create_token(client, auth_headers, + scopes=['applications.create']).get_json()['data']['secret'] + + # settings.edit is not scoped -> 403 despite the owner being admin. + response = client.put('/api/settings/site_name', + json={'value': 'Hacked'}, headers=_pat_headers(secret)) + assert response.status_code == 403 + + +def test_scoped_token_denied_on_require_role_route(client, db, admin_user, + auth_headers): + """Scoped tokens cannot pass a role gate. Vendor writes are require_role + admin, so even an admin-owned scoped token is denied.""" + secret = _create_token(client, auth_headers, + scopes=['applications.create']).get_json()['data']['secret'] + + response = client.post('/api/vendors', json={'vendor': 'RoleGated'}, + headers=_pat_headers(secret)) + assert response.status_code == 403 + assert Vendor.query.filter_by(vendor='RoleGated').first() is None + + +def test_unscoped_token_unchanged(client, db, admin_user, auth_headers): + """Regression: an unscoped admin token still acts with full authority, + including role-gated writes.""" + secret = _create_token(client, auth_headers).get_json()['data']['secret'] + + response = client.post('/api/vendors', json={'vendor': 'Unscoped Vendor'}, + headers=_pat_headers(secret)) + assert response.status_code == 201 + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first() + assert token.scopelist is None + + +def test_unknown_scope_name_rejected_at_create(client, db, admin_user, + auth_headers): + """An unknown permission name in scopes is rejected 400, listing it.""" + response = _create_token(client, auth_headers, scopes=['not.a.permission']) + assert response.status_code == 400 + assert 'not.a.permission' in response.get_json()['data']['error']['message'] + + +def test_scope_ceiling_rejects_permission_owner_lacks(client, db): + """The scope ceiling: a non-admin cannot mint a token scoped to a + permission they do not hold.""" + _user, headers = _user_with_perms(client, db, 'tokenmaker', + ['apitokens.create']) + response = _create_token(client, headers, scopes=['applications.create']) + assert response.status_code == 400 + assert 'applications.create' in response.get_json()['data']['error']['message'] + + +def test_non_admin_scoped_token_capped_at_own_permissions(client, db): + """A non-admin holding applications.create + apitokens.create may mint a + token scoped to applications.create and use it.""" + _user, headers = _user_with_perms( + client, db, 'tokenmaker', ['apitokens.create', 'applications.create']) + secret = _create_token(client, headers, + scopes=['applications.create']).get_json()['data']['secret'] + + response = client.post('/api/applications', json={'appname': 'Capped App'}, + headers=_pat_headers(secret)) + assert response.status_code == 201 + + +def test_scoped_token_intersects_at_use_time(client, db): + """Defense in depth: if the owner loses a scoped permission after the token + is minted, the token can no longer use it (use-time intersection).""" + from shopdb.core.models import Role + _user, headers = _user_with_perms( + client, db, 'tokenmaker', ['apitokens.create', 'applications.create']) + secret = _create_token(client, headers, + scopes=['applications.create']).get_json()['data']['secret'] + + # Strip applications.create from the owner's role. + role = Role.query.filter_by(rolename='tokenmakerrole').first() + role.permissions = [p for p in role.permissions + if p.name != 'applications.create'] + _db.session.commit() + + response = client.post('/api/applications', json={'appname': 'Gone'}, + headers=_pat_headers(secret)) + assert response.status_code == 403 + assert Application.query.filter_by(appname='Gone').first() is None + + +def test_import_mode_denied_over_scoped_admin_token(client, db, admin_user, + auth_headers): + """A scoped admin token does NOT get import mode (an admin-role capability): + the createddate in the body is ignored and 'now' is stamped instead.""" + secret = _create_token( + client, auth_headers, + scopes=['applications.create']).get_json()['data']['secret'] + + headers = _pat_headers(secret) + headers['X-Import-Mode'] = 'true' + before = _naive_utcnow() + response = client.post( + '/api/applications', + json={'appname': 'No Backdate', 'createddate': '2019-01-02 03:04:05'}, + headers=headers) + assert response.status_code == 201 + + app = Application.query.filter_by(appname='No Backdate').first() + assert app is not None + # Import mode was denied, so createddate is ~now, not the 2019 value. + assert app.createddate >= before + + +def test_scopes_update_round_trip(client, db, admin_user, auth_headers): + """Scopes can be set, changed, and cleared via PUT, and surface in GET.""" + create = _create_token(client, auth_headers, scopes=['applications.create']) + tokenid = create.get_json()['data']['tokenid'] + + got = client.get('/api/apitokens', headers=auth_headers).get_json()['data'] + row = next(t for t in got if t['tokenid'] == tokenid) + assert row['scopes'] == ['applications.create'] + + # Change scopes. + updated = client.put(f'/api/apitokens/{tokenid}', + json={'scopes': ['applications.edit']}, + headers=auth_headers) + assert updated.status_code == 200 + assert updated.get_json()['data']['scopes'] == ['applications.edit'] + + # Clear scopes (back to unscoped/full). + cleared = client.put(f'/api/apitokens/{tokenid}', json={'scopes': None}, + headers=auth_headers) + assert cleared.status_code == 200 + assert cleared.get_json()['data']['scopes'] is None + + +def test_collector_scoped_token_denied_on_normal_endpoints(client, db, + admin_user, + auth_headers): + """A collector-scoped token (collector.ingest only) is contained: the shim + still swaps it into a JWT, but collector.ingest gates no normal route, so it + is denied on a permission-gated create just like any other unlisted scope.""" + secret = _create_token( + client, auth_headers, + scopes=['collector.ingest']).get_json()['data']['secret'] + + response = client.post('/api/applications', json={'appname': 'Nope'}, + headers=_pat_headers(secret)) + assert response.status_code == 403 + assert Application.query.filter_by(appname='Nope').first() is None + + +def test_update_scopes_ceiling_enforced(client, db): + """PUT scopes is also capped at the owner's permissions.""" + _user, headers = _user_with_perms(client, db, 'tokenmaker', + ['apitokens.create']) + tokenid = _create_token(client, headers).get_json()['data']['tokenid'] + + response = client.put(f'/api/apitokens/{tokenid}', + json={'scopes': ['applications.delete']}, + headers=headers) + assert response.status_code == 400 + assert 'applications.delete' in response.get_json()['data']['error']['message'] diff --git a/tests/test_core/test_auth_ratelimit.py b/tests/test_core/test_auth_ratelimit.py new file mode 100644 index 00000000..84ab1e71 --- /dev/null +++ b/tests/test_core/test_auth_ratelimit.py @@ -0,0 +1,70 @@ +"""Tests for IP-based login rate limiting. + +The limiter is disabled in TestingConfig so login-heavy fixtures do not trip +it. These tests flip it on via app.config overrides and clear the shared cache +so counters do not bleed between cases. +""" + +import pytest + +from shopdb.extensions import cache + + +@pytest.fixture +def ratelimit_on(app): + """Enable a tight login rate limit for the duration of one test.""" + saved = { + 'enabled': app.config.get('AUTH_RATELIMIT_ENABLED'), + 'max': app.config.get('AUTH_RATELIMIT_MAX'), + 'window': app.config.get('AUTH_RATELIMIT_WINDOW_SECONDS'), + } + app.config['AUTH_RATELIMIT_ENABLED'] = True + app.config['AUTH_RATELIMIT_MAX'] = 3 + app.config['AUTH_RATELIMIT_WINDOW_SECONDS'] = 300 + with app.app_context(): + cache.clear() + yield + with app.app_context(): + cache.clear() + app.config['AUTH_RATELIMIT_ENABLED'] = saved['enabled'] + app.config['AUTH_RATELIMIT_MAX'] = saved['max'] + app.config['AUTH_RATELIMIT_WINDOW_SECONDS'] = saved['window'] + + +def _bad_login(client, ip): + """Attempt a login for a non-existent user from a given source IP.""" + return client.post( + '/api/auth/login', + json={'username': 'ghost', 'password': 'wrong'}, + headers={'X-Forwarded-For': ip}, + ) + + +def test_login_flood_from_one_ip_is_rate_limited(client, db, ratelimit_on): + """After the per-window budget is spent, further attempts get 429.""" + ip = '203.0.113.10' + for _ in range(3): + assert _bad_login(client, ip).status_code == 401 + # Fourth attempt in the same window is over budget. + blocked = _bad_login(client, ip) + assert blocked.status_code == 429 + assert blocked.get_json()['data']['error']['code'] == 'RATE_LIMITED' + + +def test_different_ip_not_penalized(client, db, ratelimit_on): + """One flooding IP does not lock out a different caller's IP.""" + flooder = '203.0.113.20' + for _ in range(4): + _bad_login(client, flooder) + assert _bad_login(client, flooder).status_code == 429 + + # A separate IP still gets the normal 401, not a 429. + other = _bad_login(client, '203.0.113.21') + assert other.status_code == 401 + + +def test_limiter_disabled_by_default_in_testing(client, db): + """With TestingConfig defaults the limiter is off; repeated logins stay 401.""" + for _ in range(10): + resp = _bad_login(client, '203.0.113.30') + assert resp.status_code == 401 diff --git a/tests/test_core/test_authz.py b/tests/test_core/test_authz.py new file mode 100644 index 00000000..75d4cd80 --- /dev/null +++ b/tests/test_core/test_authz.py @@ -0,0 +1,342 @@ +"""Authorization gating and login-lockout tests. + +Covers the security fixes: + - write routes require a permission/role, not just authentication + - the admin role bypasses permission checks + - repeated bad logins lock the account + +The parametrized sweep at the bottom is the standing CI guard: it walks the +whole URL map and proves every mutating route (POST/PUT/PATCH/DELETE) either +rejects a role-less member with 403 or is on the deliberate exemption list. +A future unguarded mutation therefore fails CI the moment it is added. +""" + +import re + +import pytest + +from shopdb import create_app + + +# HTTP verbs that change state. GETs are intentionally left alone (kiosk pages +# rely on optional-jwt reads). +MUTATING_METHODS = {'POST', 'PUT', 'PATCH', 'DELETE'} + +# Blueprints exempt by design (see task/ADR-006): +# auth - login/refresh/logout are the auth surface itself +# collector - agent ingest, authenticated by per-plugin API key (ADR-006) +# setup - first-run wizard, self-gated by the needs-admin state +EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'} + +# Individual endpoints exempt by design. +# knowledgebase.track_click - kiosk click-through counter. Optional-jwt +# telemetry: increments a click count on an already-active article and +# returns its link URL. Takes no caller-supplied data; gating it would +# break the unauthenticated kiosk redirect it exists to serve. +# users.update_user - admin-or-self (inline check, not a flat deny): a +# role-less member MAY edit their own record, so it does not fit the +# 403-for-every-member contract this sweep asserts. The other-user 403 is +# covered by test_member_cannot_update_other_user below. +# The apitokens create/update/revoke endpoints are NOT exempt: they now require +# the apitokens.create permission, so a role-less member gets the 403 this sweep +# asserts (ownership is still enforced inside the handler for non-admins). +# geenforce.post_report - GE-Enforce client ingest, authenticated by a +# geenforce.report managed service token (X-API-Key/Bearer), not JWT. Same +# shape as the exempt collector blueprint; the geenforce admin endpoints in +# the same blueprint are JWT+permission gated and ARE swept. +EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user', + 'geenforce.post_report'} + + +@pytest.fixture(autouse=True) +def _selfhosted_directory_modes(db): + # The usb/employees plugins default to EXTERNAL directory mode when no + # setting row exists, so exercising their handlers reaches for a MySQL + # server that exists on the dev box but not in CI. Pin selfhosted mode + # so the sweep is deterministic everywhere. + from shopdb.core.models import Setting + db.session.add(Setting(key='usb_directory_mode', value='selfhosted', + valuetype='string', category='site')) + db.session.add(Setting(key='employee_directory_mode', value='selfhosted', + valuetype='string', category='site')) + db.session.commit() + + +def _fill_url(rule): + """Turn a Werkzeug rule into a concrete path (int/float params -> 1, else x).""" + def repl(match): + converter = match.group(1) or '' + return '1' if converter.startswith(('int', 'float')) else 'x' + # matches , , , ... + return re.sub(r'<(?:([^:>]+):)?[^>]+>', repl, rule) + + +def _mutating_routes(): + """Every non-exempt mutating (endpoint, method, url) in the URL map. + + Enumerated from a throwaway app: route strings are stable across app + instances, so the requests below run against the fixture client's app. + """ + app = create_app('testing') + routes = [] + for rule in app.url_map.iter_rules(): + methods = (rule.methods or set()) & MUTATING_METHODS + if not methods: + continue + blueprint = rule.endpoint.split('.')[0] + if blueprint in EXEMPT_BLUEPRINTS or rule.endpoint in EXEMPT_ENDPOINTS: + continue + for method in sorted(methods): + routes.append((rule.endpoint, method, _fill_url(rule.rule))) + return routes + + +# Collected once at import so each route is its own parametrized case (a +# newly-unguarded mutation shows up as a single named CI failure). +MUTATING_ROUTES = _mutating_routes() +ROUTE_IDS = [f'{ep}[{method}]' for ep, method, _ in MUTATING_ROUTES] + + +def test_member_cannot_write_settings(client, db, member_headers): + """An authenticated user with no roles is forbidden from editing settings.""" + from shopdb.core.models import Setting + + setting = Setting(key='zabbix_enabled', value='false', valuetype='boolean', + category='integrations') + db.session.add(setting) + db.session.commit() + + response = client.put('/api/settings/zabbix_enabled', + json={'value': True}, headers=member_headers) + assert response.status_code == 403 + assert response.get_json()['data']['error']['code'] == 'FORBIDDEN' + + +def test_admin_can_write_settings(client, db, auth_headers): + """The admin role bypasses the permission check and may edit settings.""" + from shopdb.core.models import Setting + + setting = Setting(key='zabbix_enabled', value='false', valuetype='boolean', + category='integrations') + db.session.add(setting) + db.session.commit() + + response = client.put('/api/settings/zabbix_enabled', + json={'value': True}, headers=auth_headers) + assert response.status_code == 200 + + +def test_member_cannot_delete_asset(client, db, member_headers): + """A role-less user cannot delete assets (was previously allowed).""" + response = client.delete('/api/assets/1', headers=member_headers) + # Forbidden by authz, not a 404 - the gate runs before the lookup. + assert response.status_code == 403 + + +def test_unauthenticated_write_is_rejected(client, db): + """No token at all still cannot reach a write route.""" + response = client.delete('/api/assets/1') + assert response.status_code in (401, 422) # missing/!invalid JWT + + +def test_member_cannot_create_business_unit(client, db, member_headers): + """Reference-data writes are admin-only.""" + response = client.post('/api/businessunits', + json={'businessunit': 'Test BU'}, headers=member_headers) + assert response.status_code == 403 + + +def test_member_cannot_list_users(client, db, member_headers): + """User listing is admin-only (converted from inline check to decorator).""" + response = client.get('/api/users', headers=member_headers) + assert response.status_code == 403 + assert response.get_json()['data']['error']['code'] == 'FORBIDDEN' + + +def test_member_cannot_create_user(client, db, member_headers): + """User creation is admin-only.""" + response = client.post('/api/users', + json={'username': 'x', 'email': 'x@test.local', + 'password': 'secret'}, + headers=member_headers) + assert response.status_code == 403 + + +def test_member_cannot_delete_role(client, db, member_headers): + """Role deletion is admin-only.""" + response = client.delete('/api/users/roles/1', headers=member_headers) + assert response.status_code == 403 + + +def test_admin_can_list_users(client, db, auth_headers): + """The admin role passes the require_role gate on user listing.""" + response = client.get('/api/users', headers=auth_headers) + assert response.status_code == 200 + + +def test_member_can_read_own_user(client, db, member_user, member_headers): + """Admin-or-self: a role-less user may read their OWN record.""" + response = client.get(f'/api/users/{member_user.userid}', + headers=member_headers) + assert response.status_code == 200 + assert response.get_json()['data']['username'] == 'testmember' + + +def test_member_cannot_read_other_user(client, db, member_user, member_headers): + """Admin-or-self: a role-less user may not read a DIFFERENT record.""" + response = client.get(f'/api/users/{member_user.userid + 999}', + headers=member_headers) + assert response.status_code == 403 + + +def test_account_locks_after_repeated_bad_logins(client, db, admin_user): + """Five bad passwords lock the account; a correct password is then refused.""" + for _ in range(5): + bad = client.post('/api/auth/login', + json={'username': 'testadmin', 'password': 'wrong'}) + assert bad.status_code == 401 + + # Account is now locked - even the correct password is refused with 403. + locked = client.post('/api/auth/login', + json={'username': 'testadmin', 'password': 'testpass'}) + assert locked.status_code == 403 + assert 'locked' in locked.get_json()['data']['error']['message'].lower() + + +def test_lockout_auto_unlocks_after_expiry(client, db, admin_user): + """Once lockeduntil is in the past the account unlocks itself: a correct + password logs in and clears the lock state.""" + from datetime import datetime, timezone, timedelta + # Simulate an expired lock (naive-UTC to match the column comparisons). + admin_user.lockeduntil = (datetime.now(timezone.utc).replace(tzinfo=None) + - timedelta(minutes=1)) + admin_user.failedlogins = 5 + db.session.commit() + + ok = client.post('/api/auth/login', + json={'username': 'testadmin', 'password': 'testpass'}) + assert ok.status_code == 200, ok.get_json() + + db.session.refresh(admin_user) + assert admin_user.lockeduntil is None + assert admin_user.failedlogins == 0 + + +def test_successful_login_resets_failed_counter(client, db, admin_user): + """A good login before the threshold clears the failure count.""" + for _ in range(3): + client.post('/api/auth/login', + json={'username': 'testadmin', 'password': 'wrong'}) + + ok = client.post('/api/auth/login', + json={'username': 'testadmin', 'password': 'testpass'}) + assert ok.status_code == 200 + + from shopdb.core.models import User + user = User.query.filter_by(username='testadmin').first() + assert user.failedlogins == 0 + assert user.lockeduntil is None + + +# --------------------------------------------------------------------------- +# Standing CI guard: sweep every mutating route. +# --------------------------------------------------------------------------- + +def test_member_cannot_update_other_user(client, db, member_user, member_headers): + """Admin-or-self write gate: a role-less user cannot edit a DIFFERENT user.""" + response = client.put(f'/api/users/{member_user.userid + 999}', + json={'firstname': 'x'}, headers=member_headers) + assert response.status_code == 403 + + +def test_route_map_has_mutating_routes(): + """Sanity: the sweep actually found routes (catches a broken collector).""" + assert len(MUTATING_ROUTES) > 50, 'route enumeration looks empty/broken' + + +@pytest.mark.parametrize('endpoint,method,url', MUTATING_ROUTES, ids=ROUTE_IDS) +def test_mutation_rejects_roleless_member(client, db, member_headers, + endpoint, method, url): + """Every non-exempt mutating route must 403 a logged-in, role-less user. + + A 200/201/400/404/409 here means the route ran its handler for an + unprivileged caller - i.e. it is not permission-gated. That is the exact + regression this guard exists to catch. + """ + response = client.open(url, method=method, headers=member_headers, json={}) + assert response.status_code == 403, ( + f'{method} {endpoint} ({url}) returned {response.status_code}, ' + f'expected 403 - route is not permission-gated. If it is intended to ' + f'be public/kiosk, add it to EXEMPT_ENDPOINTS with a reason.' + ) + assert response.get_json()['data']['error']['code'] == 'FORBIDDEN' + + +@pytest.mark.parametrize('endpoint,method,url', MUTATING_ROUTES, ids=ROUTE_IDS) +def test_mutation_admin_passes_authz(client, db, auth_headers, + endpoint, method, url): + """Admin must clear the gate on every mutating route (never 401/403). + + Downstream the handler may 400/404/409/422 on the empty body - that is + validation, not authorization, and is fine. We only assert the authz gate + let the admin through. + """ + response = client.open(url, method=method, headers=auth_headers, json={}) + assert response.status_code not in (401, 403), ( + f'{method} {endpoint} ({url}) returned {response.status_code} for ' + f'admin - the admin role should bypass every permission check.' + ) + + +# --- Settings exposure allowlist (unauthenticated recon guard) --------------- + +def _seed_settings(db): + from shopdb.core.models import Setting + db.session.add_all([ + Setting(key='facility_name', value='WJ', valuetype='string', category='site'), + Setting(key='brand_primary_color', value='#123', valuetype='string', category='branding'), + Setting(key='smtp_host', value='mail.internal', valuetype='string', category='communication'), + Setting(key='employee_db_host', value='hr.internal', valuetype='string', category='integrations'), + Setting(key='zabbix_token', value='supersecret', valuetype='string', category='integrations'), + ]) + db.session.commit() + + +def test_unauthenticated_settings_list_is_allowlisted(client, db): + """No token: only the public allowlist (branding + bootstrap keys) is + visible; internal infra config is not enumerable.""" + _seed_settings(db) + keys = {s['key'] for s in client.get('/api/settings').get_json()['data']} + assert 'facility_name' in keys and 'brand_primary_color' in keys + assert 'smtp_host' not in keys + assert 'employee_db_host' not in keys + assert 'zabbix_token' not in keys + + +def test_authenticated_settings_list_full_but_masked(client, db, member_headers): + """Any authed principal sees the full table; secret values stay masked.""" + _seed_settings(db) + rows = {s['key']: s['value'] + for s in client.get('/api/settings', headers=member_headers).get_json()['data']} + assert 'smtp_host' in rows and 'employee_db_host' in rows + assert rows['zabbix_token'] == '********' # secret masked even when visible + + +def test_unauthenticated_private_setting_get_is_404(client, db): + """A single private key is not confirmable without auth; a public one is.""" + _seed_settings(db) + assert client.get('/api/settings/smtp_host').status_code == 404 + assert client.get('/api/settings/facility_name').status_code == 200 + + +# --- audit.view gating ------------------------------------------------------- + +def test_member_cannot_read_auditlogs(client, db, member_headers): + """Audit-read routes now require audit.view; a role-less member is 403.""" + for url in ('/api/auditlogs', '/api/auditlogs/entity/Setting/1', '/api/auditlogs/stats'): + assert client.get(url, headers=member_headers).status_code == 403, url + + +def test_admin_can_read_auditlogs(client, db, auth_headers): + """Admin holds audit.view and reads the audit trail.""" + assert client.get('/api/auditlogs', headers=auth_headers).status_code == 200 diff --git a/tests/test_core/test_collector_contract.py b/tests/test_core/test_collector_contract.py new file mode 100644 index 00000000..175b0db2 --- /dev/null +++ b/tests/test_core/test_collector_contract.py @@ -0,0 +1,434 @@ +"""Tests for the generic plugin collector contract (ADR-006). + +Covers the auto-dispatched /api/collector/ endpoint, per-plugin API +key auth, idempotent upsert by identity field, and the JWT-protected +/api/collector/_schemas listing. +""" + +import pytest + +KEY = 'testcollectorkey' + + +@pytest.fixture +def collector_key(app): + """Set the shared collector API key for the test.""" + old = app.config.get('COLLECTOR_API_KEY') + app.config['COLLECTOR_API_KEY'] = KEY + yield KEY + app.config['COLLECTOR_API_KEY'] = old + + +@pytest.fixture +def computer_assettype(db): + """Seed the computer asset type needed for create-on-ingest.""" + from shopdb.core.models import AssetType + + at = AssetType(assettype='computer', pluginname='computers', + tablename='computers', description='PCs') + db.session.add(at) + db.session.commit() + return at + + +def test_schemas_requires_jwt(client, db): + """The schema listing rejects unauthenticated callers.""" + response = client.get('/api/collector/_schemas') + assert response.status_code == 401 + + +def test_schemas_lists_computers(client, db, auth_headers): + """Computers plugin exposes a collector schema keyed by hostname.""" + response = client.get('/api/collector/_schemas', headers=auth_headers) + assert response.status_code == 200 + schemas = response.get_json()['data']['schemas'] + assert 'computers' in schemas + assert schemas['computers']['identityfield'] == 'hostname' + + +def test_missing_key_rejected(client, db, collector_key, computer_assettype): + """No API key -> 401.""" + response = client.post('/api/collector/computers', + json={'hostname': 'WJPC001'}) + assert response.status_code == 401 + + +def test_unknown_plugin_404(client, db, collector_key): + """A plugin with no collector schema returns 404.""" + response = client.post('/api/collector/nosuchplugin', + json={'hostname': 'x'}, + headers={'X-API-Key': KEY}) + assert response.status_code == 404 + + +def test_missing_identity_rejected(client, db, collector_key, computer_assettype): + """Missing the identity field is a validation error.""" + response = client.post('/api/collector/computers', + json={'currentuser': 'someone'}, + headers={'X-API-Key': KEY}) + assert response.status_code == 400 + + +def test_create_then_idempotent_update(client, db, collector_key, + computer_assettype): + """First post creates; second post with same hostname updates (no dup).""" + from plugins.computers.models import Computer + + payload = {'hostname': 'WJPC100', 'currentuser': 'alice', + 'serialnumber': 'SN-100'} + + first = client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': KEY}) + assert first.status_code == 200, first.get_json() + data = first.get_json()['data'] + assert data['action'] == 'created' + assert data['assetid'] is not None + assert data['identityvalue'] == 'WJPC100' + + payload['currentuser'] = 'bob' + second = client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': KEY}) + assert second.status_code == 200 + assert second.get_json()['data']['action'] == 'updated' + + with client.application.app_context(): + comps = Computer.query.filter(Computer.hostname.ilike('WJPC100')).all() + assert len(comps) == 1 + assert comps[0].loggedinuser == 'bob' + + +def test_per_plugin_key_overrides_shared(client, db, app, computer_assettype): + """COLLECTOR_API_KEY_ takes precedence over the shared key.""" + app.config['COLLECTOR_API_KEY'] = 'sharedkey' + app.config['COLLECTOR_API_KEY_COMPUTERS'] = 'computerskey' + try: + # Shared key now rejected for this plugin. + rejected = client.post('/api/collector/computers', + json={'hostname': 'WJPC200'}, + headers={'X-API-Key': 'sharedkey'}) + assert rejected.status_code == 401 + + accepted = client.post('/api/collector/computers', + json={'hostname': 'WJPC200'}, + headers={'X-API-Key': 'computerskey'}) + assert accepted.status_code == 200 + finally: + app.config.pop('COLLECTOR_API_KEY_COMPUTERS', None) + app.config['COLLECTOR_API_KEY'] = None + + +def test_complete_asset_payload_maps_enrollment_data(client, db, collector_key, + computer_assettype): + """A GE-Enforce-shaped payload maps machinenumber/pctype/vendor/model/os.""" + from shopdb.core.models import OperatingSystem + from plugins.computers.models import Computer, ComputerType + + # Seed the controlled-vocab rows the collector looks up. + db.session.add(ComputerType(computertype='Shopfloor PC')) + db.session.add(OperatingSystem(osname='Windows 11')) + db.session.commit() + + payload = { + 'hostname': 'WJSF1234', + 'machinenumber': '0615', + 'pctype': 'gea-shopfloor-collections', + 'serialnumber': 'SN-ENROLL', + 'vendorname': 'Dell', + 'modelnumber': 'OptiPlex 7090', + 'osname': 'Windows 11', + } + resp = client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': KEY}) + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['action'] == 'created' + + with client.application.app_context(): + comp = Computer.query.filter(Computer.hostname.ilike('WJSF1234')).first() + assert comp is not None + assert comp.asset.assetnumber == '0615' # machinenumber -> assetnumber + assert comp.asset.serialnumber == 'SN-ENROLL' + assert comp.computertype.computertype == 'Shopfloor PC' # pctype mapped + assert comp.vendor.vendor == 'Dell' # created + assert comp.model.modelnumber == 'OptiPlex 7090' # created + assert comp.operatingsystem.osname == 'Windows 11' + + +def test_placeholder_machinenumber_9999_falls_back_to_hostname(client, db, + collector_key, + computer_assettype): + """The 9999 imaging placeholder is ignored; assetnumber falls back to host.""" + resp = client.post('/api/collector/computers', + json={'hostname': 'WJSF9999', 'machinenumber': '9999'}, + headers={'X-API-Key': KEY}) + assert resp.status_code == 200 + from plugins.computers.models import Computer + with client.application.app_context(): + comp = Computer.query.filter(Computer.hostname.ilike('WJSF9999')).first() + assert comp.asset.assetnumber == 'WJSF9999' + + +def test_internal_error_message_is_generic(client, app, db, collector_key): + """An unexpected upsert failure returns a generic 500, not the exception text.""" + pm = app.extensions['plugin_manager'] + plugin = pm.get_all_plugins()['computers'] + original = plugin.apply_collector_payload + + def boom(payload): + raise RuntimeError('secret internal dsn leaked here') + + plugin.apply_collector_payload = boom + try: + resp = client.post('/api/collector/computers', + json={'hostname': 'WJPC500'}, + headers={'X-API-Key': KEY}) + finally: + plugin.apply_collector_payload = original + + assert resp.status_code == 500 + message = resp.get_json()['data']['error']['message'] + assert message == 'Internal error processing collector payload' + assert 'secret internal dsn' not in message + + +def test_generic_querystring_api_key_rejected(client, db, collector_key, + computer_assettype): + """The dropped ?api_key= querystring fallback no longer authenticates.""" + resp = client.post('/api/collector/computers?api_key=' + KEY, + json={'hostname': 'WJPCQS'}) + assert resp.status_code == 401 + + +def test_legacy_querystring_api_key_rejected(client, db, collector_key): + """Header-only auth on the legacy endpoints too: querystring key is rejected.""" + resp = client.get('/api/collector/status?api_key=' + KEY) + assert resp.status_code == 401 + + +def test_legacy_header_api_key_accepted(client, db, collector_key): + """The X-API-Key header still authenticates the legacy endpoints.""" + resp = client.get('/api/collector/status', headers={'X-API-Key': KEY}) + assert resp.status_code == 200 + + +# --- Managed collector service tokens ------------------------------------ + +def _mint_collector_token(client, auth_headers, scopes): + """Mint a managed token with the given scopes as the logged-in admin.""" + resp = client.post('/api/apitokens', + json={'name': 'collector svc', 'scopes': scopes}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['secret'] + + +def test_collector_token_via_x_api_key(client, db, auth_headers, + computer_assettype): + """A collector-scoped managed token authorizes ingest via X-API-Key, with + no env key set.""" + secret = _mint_collector_token(client, auth_headers, ['collector.ingest']) + resp = client.post('/api/collector/computers', + json={'hostname': 'WJTOK01'}, + headers={'X-API-Key': secret}) + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['action'] == 'created' + + +def test_collector_token_via_bearer(client, db, auth_headers, + computer_assettype): + """The same token also works on the standard Bearer PAT transport.""" + secret = _mint_collector_token(client, auth_headers, ['collector.ingest']) + resp = client.post('/api/collector/computers', + json={'hostname': 'WJTOK02'}, + headers={'Authorization': f'Bearer {secret}'}) + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['action'] == 'created' + + +def test_collector_token_stamps_lastusedat(client, db, auth_headers, + computer_assettype): + """Using a collector token stamps lastusedat.""" + from shopdb.core.models import ApiToken + + secret = _mint_collector_token(client, auth_headers, ['collector.ingest']) + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first() + assert token.lastusedat is None + + client.post('/api/collector/computers', json={'hostname': 'WJTOK03'}, + headers={'X-API-Key': secret}) + db.session.expire_all() + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first() + assert token.lastusedat is not None + + +def test_unscoped_token_rejected_by_collector(client, db, auth_headers, + collector_key, computer_assettype): + """An unscoped PAT is NOT a collector token: it lacks the collector.ingest + scope, so the collector API rejects it (env-key semantics unchanged).""" + secret = client.post('/api/apitokens', json={'name': 'full'}, + headers=auth_headers).get_json()['data']['secret'] + resp = client.post('/api/collector/computers', json={'hostname': 'WJTOK04'}, + headers={'X-API-Key': secret}) + assert resp.status_code == 401 + + +def test_wrong_scoped_token_rejected_by_collector(client, db, auth_headers, + collector_key, + computer_assettype): + """A PAT scoped to some OTHER permission is not a collector token.""" + secret = _mint_collector_token(client, auth_headers, ['applications.create']) + resp = client.post('/api/collector/computers', json={'hostname': 'WJTOK05'}, + headers={'X-API-Key': secret}) + assert resp.status_code == 401 + + +def test_revoked_collector_token_rejected(client, db, auth_headers, + collector_key, computer_assettype): + """A revoked collector token is rejected on both transports.""" + from shopdb.core.models import ApiToken + + secret = _mint_collector_token(client, auth_headers, ['collector.ingest']) + tokenid = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first().tokenid + assert client.delete(f'/api/apitokens/{tokenid}', + headers=auth_headers).status_code == 200 + + viakey = client.post('/api/collector/computers', json={'hostname': 'WJTOK06'}, + headers={'X-API-Key': secret}) + assert viakey.status_code == 401 + viabearer = client.post('/api/collector/computers', + json={'hostname': 'WJTOK06'}, + headers={'Authorization': f'Bearer {secret}'}) + assert viabearer.status_code == 401 + + +def test_collector_token_contained_to_collector_api(client, db, admin_user, + auth_headers): + """Containment: a collector-scoped token is denied on a permission-gated + write (applications.create) AND a role-gated write (vendors, require_role).""" + from shopdb.core.models import Application, Vendor + + secret = _mint_collector_token(client, auth_headers, ['collector.ingest']) + + perm_gated = client.post('/api/applications', json={'appname': 'Nope'}, + headers={'Authorization': f'Bearer {secret}'}) + assert perm_gated.status_code == 403 + assert Application.query.filter_by(appname='Nope').first() is None + + role_gated = client.post('/api/vendors', json={'vendor': 'Nope'}, + headers={'Authorization': f'Bearer {secret}'}) + assert role_gated.status_code == 403 + assert Vendor.query.filter_by(vendor='Nope').first() is None + + +def test_env_key_still_works_alongside_tokens(client, db, collector_key, + computer_assettype): + """Regression: the env key path is unchanged when tokens are in play.""" + resp = client.post('/api/collector/computers', json={'hostname': 'WJENV01'}, + headers={'X-API-Key': KEY}) + assert resp.status_code == 200 + assert resp.get_json()['data']['action'] == 'created' + + +# -- metrology measuring-tool auto-link ------------------------------------ +# A CMM/Keyence/Genspect/wax-trace PC stays a shopfloor PC, but the collector +# creates the attached measuring instrument as a MeasuringTool asset and links +# the PC to it with a directional 'controls' relationship. + +@pytest.fixture +def measuringtool_env(db): + """Seed the measuring_tool asset type + 'controls' relationship type the + metrology collector path links through.""" + from shopdb.core.models import AssetType, RelationshipType + db.session.add(AssetType(assettype='measuring_tool', + pluginname='measuringtools', + tablename='measuringtools', + description='Metrology instruments')) + db.session.add(RelationshipType(relationshiptype='controls', + description='Operational authority', + isdirectional=True)) + db.session.commit() + + +def test_metrology_pctype_creates_and_links_measuringtool( + client, db, collector_key, computer_assettype, measuringtool_env): + """A CMM PC gets a MeasuringTool asset + controls link; the CMM tool type + is auto-created (it is not in the measuringtools starter seed).""" + from plugins.computers.models import Computer + from plugins.measuringtools.models import MeasuringTool, MeasuringToolType + from shopdb.core.models import AssetRelationship + + payload = {'hostname': 'WJCMM01', 'pctype': 'gea-shopfloor-cmm'} + resp = client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': KEY}) + assert resp.status_code == 200, resp.get_json() + data = resp.get_json()['data'] + assert data['measuringtoollinkcount'] == 1 + link = data['measuringtoollinks'][0] + assert link['relationshiptype'] == 'controls' + assert link['measuringtooltype'] == 'CMM' + + with client.application.app_context(): + tools = MeasuringTool.query.all() + assert len(tools) == 1 + assert MeasuringToolType.query.filter_by(name='CMM').first() is not None + pc = Computer.query.filter(Computer.hostname.ilike('WJCMM01')).first() + rels = AssetRelationship.query.filter_by( + sourceassetid=pc.assetid, label='collector:measuringtool').all() + assert len(rels) == 1 + assert rels[0].targetassetid == tools[0].assetid + assert rels[0].isactive + + +def test_metrology_link_idempotent(client, db, collector_key, + computer_assettype, measuringtool_env): + """Re-pushing the same metrology PC does not duplicate the tool asset.""" + from plugins.measuringtools.models import MeasuringTool + + payload = {'hostname': 'WJCMM02', 'pctype': 'gea-shopfloor-cmm'} + for _ in range(2): + resp = client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': KEY}) + assert resp.status_code == 200 + with client.application.app_context(): + assert MeasuringTool.query.count() == 1 + + +def test_nonmetrology_pctype_makes_no_tool(client, db, collector_key, + computer_assettype, measuringtool_env): + """A plain shopfloor PC gets no measuring-tool asset.""" + from plugins.measuringtools.models import MeasuringTool + + payload = {'hostname': 'WJPC900', 'pctype': 'gea-shopfloor-collections'} + resp = client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': KEY}) + assert resp.status_code == 200 + assert resp.get_json()['data']['measuringtoollinkcount'] == 0 + with client.application.app_context(): + assert MeasuringTool.query.count() == 0 + + +def test_repurposed_pc_archives_tool_link(client, db, collector_key, + computer_assettype, measuringtool_env): + """A metrology PC re-imaged to a non-metrology type has its collector tool + link archived (tool asset kept, relationship deactivated).""" + from plugins.measuringtools.models import MeasuringTool + from shopdb.core.models import AssetRelationship + + host = {'hostname': 'WJCMM03'} + client.post('/api/collector/computers', + json={**host, 'pctype': 'gea-shopfloor-cmm'}, + headers={'X-API-Key': KEY}) + resp = client.post('/api/collector/computers', + json={**host, 'pctype': 'gea-shopfloor-collections'}, + headers={'X-API-Key': KEY}) + assert resp.status_code == 200 + assert resp.get_json()['data']['measuringtoollinkcount'] == 0 + with client.application.app_context(): + # asset preserved (may carry calibration history), link deactivated + assert MeasuringTool.query.count() == 1 + rels = AssetRelationship.query.filter_by( + label='collector:measuringtool').all() + assert len(rels) == 1 + assert rels[0].isactive is False diff --git a/tests/test_core/test_collector_printers.py b/tests/test_core/test_collector_printers.py new file mode 100644 index 00000000..ac48bd93 --- /dev/null +++ b/tests/test_core/test_collector_printers.py @@ -0,0 +1,199 @@ +"""Tests for the computers collector PC->printer relationship sync. + +A collector payload carrying defaultprinter/printers creates AssetRelationship +rows linking the PC asset to the resolved printer assets ('defaultprinter' for +the default, 'connectedto' for the rest). Stale collector links archive on +re-report; manual links survive; unresolved identifiers warn without failing. +""" + +import pytest + +from shopdb.core.models import ( + Asset, AssetType, AssetRelationship, RelationshipType, CommunicationType, + Communication) +from plugins.printers.models import Printer + +KEY = 'testcollectorkey' + + +@pytest.fixture +def collector_key(app): + old = app.config.get('COLLECTOR_API_KEY') + app.config['COLLECTOR_API_KEY'] = KEY + yield KEY + app.config['COLLECTOR_API_KEY'] = old + + +@pytest.fixture +def scene(db): + """Computer + printer asset types, the two relationship types, and three + printer assets identified by windowsname / hostname / IP.""" + pc_type = AssetType(assettype='computer', pluginname='computers', + tablename='computers') + pr_type = AssetType(assettype='printer', pluginname='printers', + tablename='printers') + dp_type = RelationshipType(relationshiptype='defaultprinter', + description='PC to default printer', + isdirectional=True) + ct_type = RelationshipType(relationshiptype='connectedto', + description='Network link', isdirectional=False) + ip_comtype = CommunicationType(comtype='IP') + db.session.add_all([pc_type, pr_type, dp_type, ct_type, ip_comtype]) + db.session.flush() + + # Printer A: matched by windowsname. Printer B: by hostname. Printer C: by IP. + a_asset = Asset(assetnumber='PRN-A', name='Materials HP', + assettypeid=pr_type.assettypeid, isactive=True) + b_asset = Asset(assetnumber='PRN-B', name='Front Office', + assettypeid=pr_type.assettypeid, isactive=True) + c_asset = Asset(assetnumber='PRN-C', name='Shipping Label', + assettypeid=pr_type.assettypeid, isactive=True) + db.session.add_all([a_asset, b_asset, c_asset]) + db.session.flush() + + db.session.add_all([ + Printer(assetid=a_asset.assetid, windowsname='HP-Materials'), + Printer(assetid=b_asset.assetid, hostname='prn-front.wjs.local'), + Printer(assetid=c_asset.assetid, windowsname='Zebra-Ship'), + ]) + db.session.add(Communication(assetid=c_asset.assetid, + comtypeid=ip_comtype.comtypeid, + ipaddress='10.1.2.3', isprimary=True)) + db.session.commit() + + return {'pc_type': pc_type, 'dp_type': dp_type, 'ct_type': ct_type, + 'a': a_asset, 'b': b_asset, 'c': c_asset} + + +def _post(client, payload): + return client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': KEY}) + + +def _pc_asset(db): + from plugins.computers.models import Computer + comp = Computer.query.first() + return comp.asset + + +def _active_links(db, pcid): + return AssetRelationship.query.filter_by( + sourceassetid=pcid, isactive=True).all() + + +def test_creates_default_and_connected_links(client, db, collector_key, scene): + """Default -> defaultprinter, others -> connectedto, resolved by three id forms.""" + resp = _post(client, { + 'hostname': 'WJPC-P1', + 'defaultprinter': 'HP-Materials', # windowsname + 'printers': ['prn-front.wjs.local', '10.1.2.3'], # hostname, IP + }) + assert resp.status_code == 200, resp.get_json() + data = resp.get_json()['data'] + assert data['printerlinkcount'] == 3 + assert not data['warnings'] + + pc = _pc_asset(db) + links = {(r.targetassetid, r.relationshiptype.relationshiptype) + for r in _active_links(db, pc.assetid)} + assert (scene['a'].assetid, 'defaultprinter') in links + assert (scene['b'].assetid, 'connectedto') in links + assert (scene['c'].assetid, 'connectedto') in links + + +def test_unresolved_identifier_warns_not_fails(client, db, collector_key, scene): + """An unknown printer identifier is a warning, the rest still link.""" + resp = _post(client, { + 'hostname': 'WJPC-P2', + 'defaultprinter': 'HP-Materials', + 'printers': ['does-not-exist'], + }) + assert resp.status_code == 200 + data = resp.get_json()['data'] + assert any('unresolved printer: does-not-exist' in w + for w in data['warnings']) + pc = _pc_asset(db) + assert data['printerlinkcount'] == 1 + assert len(_active_links(db, pc.assetid)) == 1 + + +def test_idempotent_no_duplicates(client, db, collector_key, scene): + """Re-report the same set: no new rows, still one link per printer.""" + payload = {'hostname': 'WJPC-P3', 'defaultprinter': 'HP-Materials', + 'printers': ['Zebra-Ship']} + assert _post(client, payload).status_code == 200 + assert _post(client, payload).status_code == 200 + + pc = _pc_asset(db) + all_rows = AssetRelationship.query.filter_by(sourceassetid=pc.assetid).all() + assert len(all_rows) == 2 # one default + one connected, no dupes + assert all(r.isactive for r in all_rows) + + +def test_dropped_printer_is_archived(client, db, collector_key, scene): + """A printer that stops being reported loses its collector link.""" + _post(client, {'hostname': 'WJPC-P4', 'defaultprinter': 'HP-Materials', + 'printers': ['Zebra-Ship']}) + pc = _pc_asset(db) + assert len(_active_links(db, pc.assetid)) == 2 + + # Re-report without Zebra-Ship. + _post(client, {'hostname': 'WJPC-P4', 'defaultprinter': 'HP-Materials', + 'printers': []}) + active = _active_links(db, pc.assetid) + assert len(active) == 1 + assert active[0].targetassetid == scene['a'].assetid + # the dropped row still exists but is inactive + zebra = AssetRelationship.query.filter_by( + sourceassetid=pc.assetid, targetassetid=scene['c'].assetid).first() + assert zebra is not None and zebra.isactive is False + + +def test_default_change_archives_old_default(client, db, collector_key, scene): + """Changing the default printer archives the previous defaultprinter link.""" + _post(client, {'hostname': 'WJPC-P5', 'defaultprinter': 'HP-Materials'}) + pc = _pc_asset(db) + _post(client, {'hostname': 'WJPC-P5', 'defaultprinter': 'prn-front.wjs.local'}) + + active = _active_links(db, pc.assetid) + assert len(active) == 1 + assert active[0].targetassetid == scene['b'].assetid + assert active[0].relationshiptype.relationshiptype == 'defaultprinter' + + +def test_manual_link_survives_collector_sync(client, db, collector_key, scene): + """A manually-created link (no collector label) is never archived.""" + # PC created first via a bare report. + _post(client, {'hostname': 'WJPC-P6', 'defaultprinter': 'HP-Materials', + 'printers': ['Zebra-Ship']}) + pc = _pc_asset(db) + + # Manual connectedto link PC -> printer B (label stays NULL). + manual = AssetRelationship( + sourceassetid=pc.assetid, targetassetid=scene['b'].assetid, + relationshiptypeid=scene['ct_type'].relationshiptypeid, notes='by hand') + db.session.add(manual) + db.session.commit() + manual_id = manual.relationshipid + + # Re-report dropping Zebra-Ship and never mentioning printer B. + _post(client, {'hostname': 'WJPC-P6', 'defaultprinter': 'HP-Materials', + 'printers': []}) + + surviving = db.session.get(AssetRelationship, manual_id) + assert surviving.isactive is True # manual link untouched + # Zebra (collector) archived. + zebra = AssetRelationship.query.filter_by( + sourceassetid=pc.assetid, targetassetid=scene['c'].assetid).first() + assert zebra.isactive is False + + +def test_no_printer_keys_leaves_links_untouched(client, db, collector_key, scene): + """A report with no printer keys does not archive existing links.""" + _post(client, {'hostname': 'WJPC-P7', 'defaultprinter': 'HP-Materials'}) + pc = _pc_asset(db) + assert len(_active_links(db, pc.assetid)) == 1 + + # A plain update (no defaultprinter, no printers key) must not archive. + _post(client, {'hostname': 'WJPC-P7', 'currentuser': 'alice'}) + assert len(_active_links(db, pc.assetid)) == 1 diff --git a/tests/test_core/test_dashboard_widgets.py b/tests/test_core/test_dashboard_widgets.py new file mode 100644 index 00000000..3bcdff4c --- /dev/null +++ b/tests/test_core/test_dashboard_widgets.py @@ -0,0 +1,38 @@ +"""Tests for the dashboard-widgets hook consumer (/api/dashboard/widgets). + +Pins the wiring added for the BasePlugin.get_dashboard_widgets hook: the +endpoint aggregates enabled plugins' widgets and skips disabled ones. + +Plugin enabled-state is monkeypatched (not persisted) so these tests do not +mutate the shared instance/plugins.json registry file. +""" + + +def _widget_plugins(client, headers): + response = client.get('/api/dashboard/widgets', headers=headers) + assert response.status_code == 200, response.get_json() + widgets = response.get_json()['data'] + assert isinstance(widgets, list) + return widgets, {w.get('plugin') for w in widgets} + + +def test_widgets_endpoint_aggregates_enabled_plugins(app, client, auth_headers, + monkeypatch): + """An enabled plugin that implements the hook contributes a widget.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + + widgets, plugins = _widget_plugins(client, auth_headers) + assert 'computers' in plugins # computers implements get_dashboard_widgets + positions = [w.get('position', 99) for w in widgets] + assert positions == sorted(positions) + + +def test_widgets_endpoint_skips_disabled_plugin(app, client, auth_headers, + monkeypatch): + """A disabled plugin's widgets drop out of the aggregate.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'computers') + + _, plugins = _widget_plugins(client, auth_headers) + assert 'computers' not in plugins diff --git a/tests/test_core/test_dashboarddefaults.py b/tests/test_core/test_dashboarddefaults.py new file mode 100644 index 00000000..34261d40 --- /dev/null +++ b/tests/test_core/test_dashboarddefaults.py @@ -0,0 +1,99 @@ +"""Tests for dashboard defaults (visitor-IP -> business-unit resolution).""" + +import pytest + + +@pytest.fixture +def businessunit(db): + from shopdb.core.models import BusinessUnit + bu = BusinessUnit(businessunit='Materials') + db.session.add(bu) + db.session.commit() + return bu + + +def test_create_and_resolve_visitor_location(client, db, auth_headers, businessunit): + """A mapped IP resolves to its business unit; explicit ipaddress param works.""" + created = client.post('/api/dashboarddefaults', json={ + 'ipaddress': '10.20.30.40', + 'businessunitid': businessunit.businessunitid, + 'description': 'Materials lobby kiosk', + }, headers=auth_headers) + assert created.status_code == 201, created.get_json() + + resolved = client.get('/api/dashboarddefaults/visitor-location?ipaddress=10.20.30.40') + assert resolved.status_code == 200 + data = resolved.get_json()['data'] + assert data['businessunitid'] == businessunit.businessunitid + assert data['businessunit'] == 'Materials' + + +def test_unmapped_ip_resolves_to_null(client, db): + """An unmapped IP returns a null business unit, not an error.""" + resolved = client.get('/api/dashboarddefaults/visitor-location?ipaddress=1.2.3.4') + assert resolved.status_code == 200 + assert resolved.get_json()['data']['businessunitid'] is None + + +def test_duplicate_ip_rejected(client, db, auth_headers, businessunit): + """Mapping the same IP twice is a conflict.""" + payload = {'ipaddress': '10.0.0.9', 'businessunitid': businessunit.businessunitid} + first = client.post('/api/dashboarddefaults', json=payload, headers=auth_headers) + assert first.status_code == 201 + dup = client.post('/api/dashboarddefaults', json=payload, headers=auth_headers) + assert dup.status_code == 409 + + +def test_non_admin_cannot_create(client, db, member_headers, businessunit): + """A logged-in non-admin is forbidden from creating a mapping (RBAC).""" + resp = client.post('/api/dashboarddefaults', json={ + 'ipaddress': '10.20.30.50', + 'businessunitid': businessunit.businessunitid, + }, headers=member_headers) + assert resp.status_code == 403 + + +def test_unauthenticated_cannot_create(client, db, businessunit): + """No token -> 401 on create.""" + resp = client.post('/api/dashboarddefaults', json={ + 'ipaddress': '10.20.30.51', + 'businessunitid': businessunit.businessunitid, + }) + assert resp.status_code == 401 + + +def test_admin_can_update_and_delete(client, db, auth_headers, businessunit): + """Admin can PUT and DELETE a mapping.""" + created = client.post('/api/dashboarddefaults', json={ + 'ipaddress': '10.20.30.60', + 'businessunitid': businessunit.businessunitid, + }, headers=auth_headers) + assert created.status_code == 201 + default_id = created.get_json()['data']['dashboarddefaultid'] + + updated = client.put(f'/api/dashboarddefaults/{default_id}', + json={'description': 'moved'}, headers=auth_headers) + assert updated.status_code == 200 + + deleted = client.delete(f'/api/dashboarddefaults/{default_id}', + headers=auth_headers) + assert deleted.status_code == 200 + + +def test_non_admin_cannot_update_or_delete(client, db, auth_headers, + member_headers, businessunit): + """A non-admin is forbidden from PUT and DELETE.""" + created = client.post('/api/dashboarddefaults', json={ + 'ipaddress': '10.20.30.70', + 'businessunitid': businessunit.businessunitid, + }, headers=auth_headers) + assert created.status_code == 201 + default_id = created.get_json()['data']['dashboarddefaultid'] + + put_resp = client.put(f'/api/dashboarddefaults/{default_id}', + json={'description': 'nope'}, headers=member_headers) + assert put_resp.status_code == 403 + + del_resp = client.delete(f'/api/dashboarddefaults/{default_id}', + headers=member_headers) + assert del_resp.status_code == 403 diff --git a/tests/test_core/test_email.py b/tests/test_core/test_email.py new file mode 100644 index 00000000..828ad7bd --- /dev/null +++ b/tests/test_core/test_email.py @@ -0,0 +1,270 @@ +"""Tests for the email service, forced password change, welcome email, and +on-demand report/alert delivery. + +smtplib is always patched so tests never touch the network. +""" + +import os +from unittest.mock import patch, MagicMock + +import pytest +from werkzeug.security import generate_password_hash + + +@pytest.fixture(autouse=True) +def _strip_smtp_env(monkeypatch): + """Remove any SMTP_* env so env-fallback cannot enable email unexpectedly.""" + for key in list(os.environ): + if key.startswith('SMTP_'): + monkeypatch.delenv(key, raising=False) + + +def _enable_smtp(db): + """Seed the minimum settings to make SMTP 'configured'.""" + from shopdb.core.models import Setting + from shopdb.core.api.settings import invalidate_settings_cache + + rows = { + 'smtp_enabled': ('true', 'boolean'), + 'smtp_host': ('smtp.test.local', 'string'), + 'smtp_port': ('587', 'integer'), + 'smtp_use_tls': ('true', 'boolean'), + 'smtp_from_address': ('noreply@test.local', 'string'), + 'smtp_from_name': ('ShopDB', 'string'), + 'alert_recipients': ('ops@test.local', 'string'), + } + for key, (value, valuetype) in rows.items(): + db.session.add(Setting(key=key, value=value, valuetype=valuetype, + category='email')) + db.session.commit() + invalidate_settings_cache() + + +def _make_user(db, username, password='temppass1', mustchange=False, + failedlogins=0): + from shopdb.core.models import User + user = User( + username=username, + email=f'{username}@test.local', + passwordhash=generate_password_hash(password), + mustchangepassword=mustchange, + failedlogins=failedlogins, + ) + db.session.add(user) + db.session.commit() + return user + + +def _login(client, username, password): + response = client.post('/api/auth/login', + json={'username': username, 'password': password}) + return response + + +# --------------------------------------------------------------------------- +# Mailer no-op behavior +# --------------------------------------------------------------------------- + +def test_mailer_noop_when_disabled(app, db): + """send_email is a no-op returning False when SMTP is disabled; no connect.""" + from shopdb.utils import mailer + with app.app_context(): + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + result = mailer.send_email('a@test.local', 'Hi', '

Hi

') + assert result is False + smtp.assert_not_called() + + +def test_mailer_sends_when_configured(app, db): + """send_email connects and sends when SMTP is configured (patched).""" + from shopdb.utils import mailer + with app.app_context(): + _enable_smtp(db) + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + instance = MagicMock() + smtp.return_value = instance + result = mailer.send_email('a@test.local', 'Hi', '

Hi

') + assert result is True + instance.sendmail.assert_called_once() + + +def test_mailer_never_logs_password(app, db): + """A send failure carrying the password is scrubbed in the returned error.""" + from shopdb.utils import mailer + with app.app_context(): + from shopdb.core.models import Setting + from shopdb.core.api.settings import invalidate_settings_cache + _enable_smtp(db) + db.session.add(Setting(key='smtp_password', value='s3cret', + valuetype='string', category='email')) + db.session.commit() + invalidate_settings_cache() + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + instance = MagicMock() + instance.sendmail.side_effect = RuntimeError('auth failed s3cret') + smtp.return_value = instance + ok, error = mailer.try_send('a@test.local', 'Hi', '

Hi

') + assert ok is False + assert 's3cret' not in error + + +# --------------------------------------------------------------------------- +# Test-email endpoint +# --------------------------------------------------------------------------- + +def test_test_email_requires_admin(client, db, member_headers): + response = client.post('/api/settings/test-email', + json={'to': 'x@test.local'}, headers=member_headers) + assert response.status_code == 403 + + +def test_test_email_graceful_when_unconfigured(client, db, auth_headers): + response = client.post('/api/settings/test-email', + json={'to': 'x@test.local'}, headers=auth_headers) + assert response.status_code == 200 + assert response.get_json()['data']['sent'] is False + + +def test_test_email_sends_when_configured(client, db, auth_headers): + _enable_smtp(db) + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + smtp.return_value = MagicMock() + response = client.post('/api/settings/test-email', + json={'to': 'x@test.local'}, headers=auth_headers) + assert response.status_code == 200 + assert response.get_json()['data']['sent'] is True + + +# --------------------------------------------------------------------------- +# Forced password change +# --------------------------------------------------------------------------- + +def test_login_surfaces_mustchangepassword(client, db): + _make_user(db, 'forceduser', password='temppass1', mustchange=True) + response = _login(client, 'forceduser', 'temppass1') + assert response.status_code == 200 + assert response.get_json()['data']['user']['mustchangepassword'] is True + + +def test_forced_change_clears_flag_and_lockout(client, db): + user = _make_user(db, 'forceduser', password='temppass1', mustchange=True, + failedlogins=3) + token = _login(client, 'forceduser', 'temppass1').get_json()['data']['access_token'] + headers = {'Authorization': f'Bearer {token}'} + # Forced case: new_password only, no current required. + response = client.post('/api/auth/change-password', + json={'new_password': 'brandnew123'}, headers=headers) + assert response.status_code == 200 + + from shopdb.core.models import User + refreshed = db.session.get(User, user.userid) + assert refreshed.mustchangepassword is False + assert refreshed.failedlogins == 0 + assert refreshed.lockeduntil is None + # New password works. + assert _login(client, 'forceduser', 'brandnew123').status_code == 200 + + +def test_selfservice_change_rejects_wrong_current(client, db): + _make_user(db, 'normaluser', password='rightpass1', mustchange=False) + token = _login(client, 'normaluser', 'rightpass1').get_json()['data']['access_token'] + headers = {'Authorization': f'Bearer {token}'} + response = client.post('/api/auth/change-password', + json={'current_password': 'wrongpass', + 'new_password': 'brandnew123'}, headers=headers) + assert response.status_code == 401 + + +def test_selfservice_change_succeeds_with_correct_current(client, db): + _make_user(db, 'normaluser', password='rightpass1', mustchange=False) + token = _login(client, 'normaluser', 'rightpass1').get_json()['data']['access_token'] + headers = {'Authorization': f'Bearer {token}'} + response = client.post('/api/auth/change-password', + json={'current_password': 'rightpass1', + 'new_password': 'brandnew123'}, headers=headers) + assert response.status_code == 200 + assert _login(client, 'normaluser', 'brandnew123').status_code == 200 + + +def test_change_password_rejects_short_password(client, db): + _make_user(db, 'normaluser', password='rightpass1', mustchange=False) + token = _login(client, 'normaluser', 'rightpass1').get_json()['data']['access_token'] + headers = {'Authorization': f'Bearer {token}'} + response = client.post('/api/auth/change-password', + json={'current_password': 'rightpass1', + 'new_password': 'short'}, headers=headers) + assert response.status_code == 400 + + +# --------------------------------------------------------------------------- +# New-user welcome +# --------------------------------------------------------------------------- + +def test_create_user_sets_mustchangepassword_and_sends(client, db, auth_headers): + _enable_smtp(db) + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + instance = MagicMock() + smtp.return_value = instance + response = client.post('/api/users', + json={'username': 'newhire', + 'email': 'newhire@test.local', + 'password': 'temppass1'}, + headers=auth_headers) + assert response.status_code == 201 + assert response.get_json()['data']['mustchangepassword'] is True + instance.sendmail.assert_called_once() + + +def test_create_user_survives_mail_failure(client, db, auth_headers): + """User is still created (with a warning) when the welcome email fails.""" + _enable_smtp(db) + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + instance = MagicMock() + instance.sendmail.side_effect = RuntimeError('relay down') + smtp.return_value = instance + response = client.post('/api/users', + json={'username': 'newhire2', + 'email': 'newhire2@test.local', + 'password': 'temppass1'}, + headers=auth_headers) + assert response.status_code == 201 + assert 'warning' in response.get_json()['data'] + + +# --------------------------------------------------------------------------- +# On-demand report/alert delivery +# --------------------------------------------------------------------------- + +def test_email_report_requires_permission(client, db, member_headers): + response = client.post('/api/reports/email', + json={'subject': 'X', 'columns': [], 'rows': []}, + headers=member_headers) + assert response.status_code == 403 + + +def test_email_report_renders_and_sends(client, db, auth_headers): + _enable_smtp(db) + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + instance = MagicMock() + smtp.return_value = instance + response = client.post('/api/reports/email', + json={'subject': 'Warranty Report', + 'columns': [{'key': 'vendor', 'label': 'Vendor'}], + 'rows': [{'vendor': 'Dell'}], + 'to': 'boss@test.local'}, + headers=auth_headers) + assert response.status_code == 200 + assert response.get_json()['data']['sent'] is True + instance.sendmail.assert_called_once() + + +def test_email_report_defaults_to_alert_recipients(client, db, auth_headers): + _enable_smtp(db) + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + instance = MagicMock() + smtp.return_value = instance + response = client.post('/api/reports/email', + json={'subject': 'Toner', 'columns': [], 'rows': []}, + headers=auth_headers) + assert response.status_code == 200 + assert response.get_json()['data']['sent'] is True diff --git a/tests/test_core/test_employees.py b/tests/test_core/test_employees.py new file mode 100644 index 00000000..aacb64ab --- /dev/null +++ b/tests/test_core/test_employees.py @@ -0,0 +1,26 @@ +"""Characterization tests for the employee directory endpoints. + +Written before extracting employees into a plugin. The wjf_employees DB is not +available under test, so these assert the DB-independent behaviors: validation +(400) and graceful failure (500 envelope, not an unhandled crash) when the +directory is unreachable. Behavior must be identical as a plugin blueprint. +""" + + +def test_search_requires_min_query(client, db): + """A too-short query is rejected before touching the directory DB.""" + resp = client.get('/api/employees/search?q=a') + assert resp.status_code == 400 + + +def test_lookup_requires_numeric_sso(client, db): + """Non-numeric SSO is rejected without touching the directory DB.""" + resp = client.get('/api/employees/lookup/abc') + assert resp.status_code == 400 + + +def test_search_degrades_when_directory_unreachable(client, db): + """With the directory DB unavailable, search returns a clean 500 envelope.""" + resp = client.get('/api/employees/search?q=smith') + assert resp.status_code == 500 + assert resp.get_json()['status'] == 'error' diff --git a/tests/test_core/test_identifiers.py b/tests/test_core/test_identifiers.py new file mode 100644 index 00000000..48b06e43 --- /dev/null +++ b/tests/test_core/test_identifiers.py @@ -0,0 +1,77 @@ +"""Tests for per-asset-type optional identifiers (gauge/maintenance refs). + +Pins two behaviors that shipped without coverage: +1. gaugelabreference + maintenancereference round-trip through each plugin's + asset create AND update endpoints (computer, printer, network). +2. The per-type identifier seed produces one setting per + (identifier x asset type) pair. +""" + +import pytest + +from shopdb.core.api.settings import IDENTIFIER_LABELS, IDENTIFIER_ASSETTYPES + + +@pytest.fixture +def asset_types(db): + """Seed the asset types the create endpoints look up by name.""" + from shopdb.core.models import AssetType + + for name in ('computer', 'printer', 'network_device'): + db.session.add(AssetType(assettype=name, pluginname=name, + tablename=name, description=name)) + db.session.commit() + + +# (endpoint prefix, extension key in the response, extra create fields) +PLUGIN_CASES = [ + ('/api/computers', 'computer', {}), + ('/api/printers', 'printer', {}), + ('/api/network', 'networkdevice', {}), +] + + +@pytest.mark.parametrize('prefix,extkey,extra', PLUGIN_CASES) +def test_gauge_maintenance_roundtrip(client, db, auth_headers, asset_types, + prefix, extkey, extra): + """Gauge/maintenance refs persist on create and update for each type.""" + payload = { + 'assetnumber': f'AST-{extkey}', + 'gaugelabreference': 'GL-1', + 'maintenancereference': 'MNT-1', + **extra, + } + created = client.post(prefix, json=payload, headers=auth_headers) + assert created.status_code == 201, created.get_json() + body = created.get_json()['data'] + assert body['gaugelabreference'] == 'GL-1' + assert body['maintenancereference'] == 'MNT-1' + + extid = body[extkey][f'{extkey}id'] if extkey != 'networkdevice' \ + else body[extkey]['networkdeviceid'] + + updated = client.put(f'{prefix}/{extid}', + json={'gaugelabreference': 'GL-2', + 'maintenancereference': 'MNT-2'}, + headers=auth_headers) + assert updated.status_code == 200, updated.get_json() + + fetched = client.get(f'{prefix}/{extid}', headers=auth_headers).get_json()['data'] + assert fetched['gaugelabreference'] == 'GL-2' + assert fetched['maintenancereference'] == 'MNT-2' + + +def test_per_type_identifier_seed_count(client, db, auth_headers): + """Seeding produces one boolean setting per identifier x asset type.""" + response = client.post('/api/settings/seed', headers=auth_headers) + assert response.status_code == 200 + + from shopdb.core.models import Setting + keys = {s.key for s in Setting.query.filter_by(category='identifiers').all()} + expected = { + f'identifier_{name}_{assettype}_enabled' + for name in IDENTIFIER_LABELS + for assettype in IDENTIFIER_ASSETTYPES + } + assert expected <= keys + assert len(expected) == len(IDENTIFIER_LABELS) * len(IDENTIFIER_ASSETTYPES) diff --git a/tests/test_core/test_import_mode.py b/tests/test_core/test_import_mode.py new file mode 100644 index 00000000..6d7011b6 --- /dev/null +++ b/tests/test_core/test_import_mode.py @@ -0,0 +1,209 @@ +"""Import-mode behavior: admin-gated legacy timestamp + event backdating. + +Covers deliverables of the import-API work: +- createddate/modifieddate passthrough on a reference entity (vendor) and an + asset plugin (computer), gated on admin + X-Import-Mode header. +- both negative cases: no header, and header sent by a non-admin. +- a natural-key exact-match lookup filter added for idempotent import. +- backdated USB checkout time in selfhosted mode. +""" + +import pytest +from werkzeug.security import generate_password_hash + +# Import the usb models at collection time so their tables register on the +# metadata before the per-test db fixture calls create_all. The usb plugin is +# disabled in the dev registry, so its tables would otherwise never be created. +from plugins.usb.models import USBDevice, USBCheckout # noqa: F401 + + +IMPORT_HEADER = {'X-Import-Mode': 'true'} +LEGACY_CREATED = '2020-01-05 08:30:00' +LEGACY_MODIFIED = '2021-06-07T14:15:16' + + +def _headers(auth_headers, importmode=True): + merged = dict(auth_headers) + if importmode: + merged.update(IMPORT_HEADER) + return merged + + +@pytest.fixture +def asset_types(db): + """Seed the asset types the plugin create endpoints look up by name.""" + from shopdb.core.models import AssetType + + for name in ('computer',): + db.session.add(AssetType(assettype=name, pluginname=name, + tablename=name, description=name)) + db.session.commit() + + +@pytest.fixture +def creator_headers(client, db): + """A NON-admin user granted only computers.create/edit, logged in. + + Proves import mode requires admin: this user carries the write permission + but must NOT get timestamp passthrough. + """ + from shopdb.core.models import User, Role, Permission + + perms = [] + for name in ('computers.create', 'computers.edit'): + p = Permission(name=name, description=name, category='computers') + db.session.add(p) + perms.append(p) + role = Role(rolename='pcoperator', description='PC operator') + role.permissions.extend(perms) + db.session.add(role) + db.session.flush() + + user = User(username='pcop', email='pcop@test.local', + passwordhash=generate_password_hash('testpass')) + user.roles.append(role) + db.session.add(user) + db.session.commit() + + response = client.post('/api/auth/login', + json={'username': 'pcop', 'password': 'testpass'}) + assert response.status_code == 200, response.get_json() + token = response.get_json()['data']['access_token'] + return {'Authorization': f'Bearer {token}'} + + +# --------------------------------------------------------------------------- +# Timestamp passthrough (positive) +# --------------------------------------------------------------------------- + +def test_admin_import_mode_preserves_vendor_timestamps(client, db, auth_headers): + """Admin + header: vendor keeps the legacy createddate/modifieddate.""" + payload = {'vendor': 'LegacyVendor', + 'createddate': LEGACY_CREATED, + 'modifieddate': LEGACY_MODIFIED} + response = client.post('/api/vendors', json=payload, + headers=_headers(auth_headers)) + assert response.status_code == 201, response.get_json() + + from shopdb.core.models import Vendor + v = Vendor.query.filter_by(vendor='LegacyVendor').first() + assert v.createddate.year == 2020 and v.createddate.month == 1 + assert v.createddate.day == 5 and v.createddate.hour == 8 + assert v.modifieddate.year == 2021 and v.modifieddate.month == 6 + + +def test_admin_import_mode_preserves_computer_asset_timestamps( + client, db, auth_headers, asset_types): + """Admin + header: the created asset row keeps its legacy createddate.""" + payload = {'assetnumber': 'PC-IMPORT-1', + 'hostname': 'legacy-pc', + 'createddate': LEGACY_CREATED, + 'modifieddate': LEGACY_MODIFIED} + response = client.post('/api/computers', json=payload, + headers=_headers(auth_headers)) + assert response.status_code == 201, response.get_json() + + from shopdb.core.models import Asset + asset = Asset.query.filter_by(assetnumber='PC-IMPORT-1').first() + assert asset.createddate.year == 2020 + assert asset.modifieddate.year == 2021 + + +# --------------------------------------------------------------------------- +# Timestamp passthrough (negative): ignored without header, or for non-admin +# --------------------------------------------------------------------------- + +def test_no_header_ignores_timestamps(client, db, auth_headers): + """Admin but NO X-Import-Mode header: createddate falls back to now.""" + payload = {'vendor': 'NoHeaderVendor', 'createddate': LEGACY_CREATED} + response = client.post('/api/vendors', json=payload, + headers=_headers(auth_headers, importmode=False)) + assert response.status_code == 201, response.get_json() + + from shopdb.core.models import Vendor + v = Vendor.query.filter_by(vendor='NoHeaderVendor').first() + assert v.createddate.year >= 2024 # server-stamped now, not the 2020 payload + + +def test_non_admin_ignores_timestamps(client, db, creator_headers, asset_types): + """Non-admin WITH the write permission + header: timestamps still ignored.""" + payload = {'assetnumber': 'PC-NONADMIN-1', + 'createddate': LEGACY_CREATED} + response = client.post('/api/computers', json=payload, + headers=_headers(creator_headers)) + assert response.status_code == 201, response.get_json() + + from shopdb.core.models import Asset + asset = Asset.query.filter_by(assetnumber='PC-NONADMIN-1').first() + assert asset.createddate.year >= 2024 # not backdated: caller is not admin + + +# --------------------------------------------------------------------------- +# Natural-key lookup filter (idempotency recipe support) +# --------------------------------------------------------------------------- + +def test_vendor_exact_lookup_filter(client, db, auth_headers): + """GET /api/vendors?vendor= exact-matches one vendor for lookup.""" + for name in ('Acme', 'AcmeTools', 'Beta'): + resp = client.post('/api/vendors', json={'vendor': name}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + + listed = client.get('/api/vendors?vendor=Acme', headers=auth_headers) + assert listed.status_code == 200 + rows = listed.get_json()['data'] + assert len(rows) == 1 + assert rows[0]['vendor'] == 'Acme' + + +# --------------------------------------------------------------------------- +# Backdated USB checkout (selfhosted mode) +# +# The usb plugin is disabled in the dev registry, so its blueprint may not be +# registered. Exercise the selfhosted layer directly inside a request context +# carrying the admin JWT + import header, which is what the route delegates to. +# --------------------------------------------------------------------------- + +def _seed_usb_device(db, serialnumber): + from plugins.usb.models import USBDevice + device = USBDevice(serialnumber=serialnumber, ischeckedout=False, isactive=True) + db.session.add(device) + db.session.commit() + return device + + +def test_usb_backdated_checkout(app, db, auth_headers): + """Admin + header: a USB checkout records the historical checkouttime.""" + from plugins.usb.api import selfhosted + from plugins.usb.models import USBDevice, USBCheckout + + device = _seed_usb_device(db, 'USB-IMPORT-1') + headers = _headers(auth_headers) + with app.test_request_context('/api/usb/USB-IMPORT-1/checkout', + method='POST', headers=headers): + resp = selfhosted.checkout_device( + 'USB-IMPORT-1', + {'badge': '570005354', 'checkouttime': '2020-03-04 09:10:11'}) + assert resp.status_code == 201, resp.get_json() + + row = USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid).first() + assert row.checkouttime.year == 2020 and row.checkouttime.month == 3 + reloaded = db.session.get(USBDevice, device.usbdeviceid) + assert reloaded.currentcheckoutdate.year == 2020 + + +def test_usb_checkout_ignores_backdate_without_header(app, db, auth_headers): + """No import header: the checkouttime override is ignored, uses now.""" + from plugins.usb.api import selfhosted + from plugins.usb.models import USBDevice, USBCheckout + + device = _seed_usb_device(db, 'USB-IMPORT-2') + with app.test_request_context('/api/usb/USB-IMPORT-2/checkout', + method='POST', headers=auth_headers): + resp = selfhosted.checkout_device( + 'USB-IMPORT-2', + {'badge': '570005354', 'checkouttime': '2020-03-04 09:10:11'}) + assert resp.status_code == 201, resp.get_json() + + row = USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid).first() + assert row.checkouttime.year >= 2024 # server now, not the 2020 override diff --git a/tests/test_core/test_knowledgebase.py b/tests/test_core/test_knowledgebase.py new file mode 100644 index 00000000..6cb2c20f --- /dev/null +++ b/tests/test_core/test_knowledgebase.py @@ -0,0 +1,34 @@ +"""Characterization tests for the Knowledge Base API. + +Written before extracting Knowledge Base into a plugin (pinning-flask-behavior): +the endpoints must behave identically whether KB is a core blueprint or a plugin +blueprint, since both register at /api/knowledgebase. +""" + + +def test_create_list_get_article(client, db, auth_headers): + """Create an article, see it in the list, fetch it by id.""" + created = client.post('/api/knowledgebase', + json={'shortdescription': 'How to reset a printer', + 'linkurl': 'https://kb.example/printer-reset', + 'keywords': 'printer reset'}, + headers=auth_headers) + assert created.status_code == 201, created.get_json() + linkid = created.get_json()['data']['linkid'] + + listing = client.get('/api/knowledgebase', headers=auth_headers) + assert listing.status_code == 200 + ids = [a['linkid'] for a in listing.get_json()['data']] + assert linkid in ids + + fetched = client.get(f'/api/knowledgebase/{linkid}', headers=auth_headers) + assert fetched.status_code == 200 + assert fetched.get_json()['data']['shortdescription'] == 'How to reset a printer' + + +def test_create_requires_shortdescription(client, db, auth_headers): + """shortdescription is required.""" + resp = client.post('/api/knowledgebase', + json={'linkurl': 'https://kb.example/x'}, + headers=auth_headers) + assert resp.status_code == 400 diff --git a/tests/test_core/test_location_code.py b/tests/test_core/test_location_code.py new file mode 100644 index 00000000..cb809eca --- /dev/null +++ b/tests/test_core/test_location_code.py @@ -0,0 +1,27 @@ +"""Unit tests for the location operation-code derivation. + +Locations carry no dedicated code column, so the operation code is the leading +whitespace-delimited token of the location name (e.g. '0615 Blisk Inspection' +-> '0615'). Printed asset labels for measuring tools encode this so every tool +at one operation shares one code. +""" + +from shopdb.core.models.location import derive_locationcode + + +def test_derive_leading_numeric_token(): + assert derive_locationcode('0615 Blisk Inspection') == '0615' + + +def test_derive_single_token_name(): + assert derive_locationcode('Lab') == 'Lab' + + +def test_derive_strips_surrounding_whitespace(): + assert derive_locationcode(' 0700 Final ') == '0700' + + +def test_derive_none_when_blank(): + assert derive_locationcode('') is None + assert derive_locationcode(' ') is None + assert derive_locationcode(None) is None diff --git a/tests/test_core/test_model_image.py b/tests/test_core/test_model_image.py new file mode 100644 index 00000000..5ed8a996 --- /dev/null +++ b/tests/test_core/test_model_image.py @@ -0,0 +1,149 @@ +"""Tests for vendor-model photo upload, replace, delete, and serve. + +Covers the core model-image endpoints: admin-gated upload/delete, the public +serve route, one-image-per-model replace semantics, and the guarantee that +deleting a model whose imageurl is an external URL clears the field without +touching the filesystem. +""" + +import io +import os + +from shopdb.core.models import Model +from shopdb.core.api.models import MODEL_IMAGE_URL_PREFIX + + +def _make_model(db, imageurl=None): + m = Model(modelnumber='TESTMODEL-1', imageurl=imageurl) + db.session.add(m) + db.session.commit() + return m + + +def test_upload_forbidden_for_non_admin(client, db, member_headers): + """A role-less authenticated user cannot upload a model image.""" + m = _make_model(db) + data = {'file': (io.BytesIO(b''), 'photo.svg')} + resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data, + content_type='multipart/form-data', headers=member_headers) + assert resp.status_code == 403 + assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN' + + +def test_upload_sets_imageurl_and_file_exists(client, db, auth_headers, app): + """Admin upload writes the served URL and the file lands in the instance dir.""" + m = _make_model(db) + payload = b'\x89PNG\r\n\x1a\nfake-png-bytes' + data = {'file': (io.BytesIO(payload), 'photo.png')} + resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + + expected_url = f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png' + assert resp.get_json()['data']['imageurl'] == expected_url + + refreshed = db.session.get(Model, m.modelnumberid) + assert refreshed.imageurl == expected_url + + path = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png') + assert os.path.exists(path) + with open(path, 'rb') as handle: + assert handle.read() == payload + + +def test_upload_rejects_invalid_extension(client, db, auth_headers): + """A disallowed file extension is rejected.""" + m = _make_model(db) + data = {'file': (io.BytesIO(b'MZ...'), 'photo.exe')} + resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 400 + assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR' + + +def test_upload_missing_model_is_404(client, db, auth_headers): + """Uploading to a nonexistent model id is a 404.""" + data = {'file': (io.BytesIO(b''), 'photo.svg')} + resp = client.post('/api/models/999999/image', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 404 + + +def test_reupload_replaces_and_removes_old_extension(client, db, auth_headers, app): + """Re-upload with a different extension deletes the prior file.""" + m = _make_model(db) + first = {'file': (io.BytesIO(b'first'), 'photo.png')} + resp = client.post(f'/api/models/{m.modelnumberid}/image', data=first, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 200 + oldpath = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png') + assert os.path.exists(oldpath) + + second = {'file': (io.BytesIO(b'second'), 'photo.jpg')} + resp = client.post(f'/api/models/{m.modelnumberid}/image', data=second, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 200 + + # Old .png is gone; new .jpg exists and imageurl points at it. + assert not os.path.exists(oldpath) + newpath = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.jpg') + assert os.path.exists(newpath) + refreshed = db.session.get(Model, m.modelnumberid) + assert refreshed.imageurl == f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.jpg' + + +def test_serve_returns_bytes(client, db, auth_headers): + """The public serve route returns the uploaded bytes without auth.""" + m = _make_model(db) + payload = b'\x89PNG\r\n\x1a\nserved-bytes' + data = {'file': (io.BytesIO(payload), 'photo.png')} + client.post(f'/api/models/{m.modelnumberid}/image', data=data, + content_type='multipart/form-data', headers=auth_headers) + + served = client.get(f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png') + assert served.status_code == 200 + assert served.get_data() == payload + + +def test_delete_clears_field_and_removes_file(client, db, auth_headers, app): + """Delete clears imageurl and removes the owned file.""" + m = _make_model(db) + data = {'file': (io.BytesIO(b'bytes'), 'photo.png')} + client.post(f'/api/models/{m.modelnumberid}/image', data=data, + content_type='multipart/form-data', headers=auth_headers) + path = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png') + assert os.path.exists(path) + + resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers) + assert resp.status_code == 200 + assert resp.get_json()['data']['imageurl'] is None + assert not os.path.exists(path) + + refreshed = db.session.get(Model, m.modelnumberid) + assert refreshed.imageurl is None + + +def test_delete_external_url_clears_field_without_filesystem_error(client, db, auth_headers): + """Delete on a model whose imageurl is an external URL just clears the field.""" + m = _make_model(db, imageurl='https://example.com/product.png') + resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers) + assert resp.status_code == 200 + assert resp.get_json()['data']['imageurl'] is None + + refreshed = db.session.get(Model, m.modelnumberid) + assert refreshed.imageurl is None + + +def test_delete_legacy_path_clears_field_without_filesystem_error(client, db, auth_headers): + """Delete on a shipped /images/models/* path clears the field, touches no disk.""" + m = _make_model(db, imageurl='/images/models/machines/legacy.png') + resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers) + assert resp.status_code == 200 + assert resp.get_json()['data']['imageurl'] is None + + +def test_delete_forbidden_for_non_admin(client, db, member_headers): + """A role-less authenticated user cannot delete a model image.""" + m = _make_model(db, imageurl='https://example.com/product.png') + resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=member_headers) + assert resp.status_code == 403 diff --git a/tests/test_core/test_permissions_catalog.py b/tests/test_core/test_permissions_catalog.py new file mode 100644 index 00000000..8f1b2d48 --- /dev/null +++ b/tests/test_core/test_permissions_catalog.py @@ -0,0 +1,90 @@ +"""Tests for the get_permissions hook consumer chain (contract 0.10.0). + +Pins full_permission_catalog() and its consumers: the core catalog holds no +plugin permissions, an enabled plugin's permissions merge in and drop out when +disabled, seeding is idempotent, API-token scope validation follows the enabled +set, and the role grid groups plugin categories. + +Plugin enabled-state is monkeypatched (not persisted) so these tests do not +mutate the shared instance/plugins.json registry file. +""" + +from shopdb.core.models import Permission, full_permission_catalog +from shopdb.core.models.apitoken import ApiToken + +# Permission-owning plugin domains that must NOT live in the core catalog. +PLUGIN_PREFIXES = ('machines.', 'computers.', 'printers.', 'network.', 'kb.', + 'notifications.', 'usb.', 'warranty.', 'measuringtools.') + + +def test_core_catalog_excludes_plugin_permissions(): + """Permission.CORE_PERMISSIONS holds only genuinely core permissions.""" + names = {n for n, _d, _c in Permission.CORE_PERMISSIONS} + for name in names: + assert not name.startswith(PLUGIN_PREFIXES), ( + f'{name} is a plugin permission and must move to its plugin hook') + # Core sets stay put. + assert 'assets.edit' in names + assert 'settings.edit' in names + assert 'collector.ingest' in names + + +def test_enabled_plugin_permissions_merge(app, monkeypatch): + """An enabled plugin's permissions appear in full_permission_catalog().""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + with app.app_context(): + names = {n for n, _d, _c in full_permission_catalog()} + assert 'machines.edit' in names + assert 'printers.edit' in names + # core still present alongside plugin permissions + assert 'assets.edit' in names + + +def test_disabled_plugin_permissions_drop_out(app, monkeypatch): + """A disabled plugin's permissions disappear from the catalog.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', + lambda name: name != 'machines') + with app.app_context(): + names = {n for n, _d, _c in full_permission_catalog()} + assert 'machines.edit' not in names + assert 'printers.edit' in names # other plugins still enabled + + +def test_seed_is_idempotent(app, db, monkeypatch): + """Seeding the full catalog twice creates each permission once.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + with app.app_context(): + first = Permission.seed() + db.session.commit() + assert first > len(Permission.CORE_PERMISSIONS) # plugins contributed + second = Permission.seed() + db.session.commit() + assert second == 0 + + +def test_token_scope_validation_follows_enabled_set(app, monkeypatch): + """A plugin permission is an accepted scope only while the plugin is enabled.""" + pm = app.extensions['plugin_manager'] + with app.app_context(): + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + assert ApiToken.unknown_scope_names(['machines.edit']) == [] + assert ApiToken.unknown_scope_names(['assets.edit']) == [] + + monkeypatch.setattr(pm.registry, 'is_enabled', + lambda name: name != 'machines') + assert ApiToken.unknown_scope_names(['machines.edit']) == ['machines.edit'] + + +def test_list_permissions_endpoint_groups_plugin_category( + app, client, auth_headers, monkeypatch): + """GET /api/users/permissions surfaces enabled plugins' categories.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + response = client.get('/api/users/permissions', headers=auth_headers) + assert response.status_code == 200, response.get_json() + grouped = response.get_json()['data']['grouped'] + assert 'assets' in grouped # core category always present + assert 'machines' in grouped # enabled plugin category merged in diff --git a/tests/test_core/test_plugins_enabled.py b/tests/test_core/test_plugins_enabled.py new file mode 100644 index 00000000..8eaf15e8 --- /dev/null +++ b/tests/test_core/test_plugins_enabled.py @@ -0,0 +1,29 @@ +"""GET /api/plugins/enabled: the flat enabled-plugin-name list that drives +frontend route gating (ADR-009). + +Pins two properties: the endpoint is reachable anonymously and returns a +plain array of names, and a disabled plugin's name drops out of that array +(so its frontend routes gate off), mirroring the search-disabled pattern. +""" + + +def test_enabled_anonymous_returns_array(client): + """No auth required (jwt-optional): returns a flat array of name strings.""" + resp = client.get('/api/plugins/enabled') + assert resp.status_code == 200, resp.get_json() + data = resp.get_json()['data'] + assert isinstance(data, list) + assert all(isinstance(name, str) for name in data) + + +def test_disabled_plugin_absent(app, client, monkeypatch): + """A disabled plugin's name is missing; enabled ones remain present.""" + pm = app.extensions['plugin_manager'] + # Pretend printers is enabled but usb is not. + monkeypatch.setattr(pm.registry, 'get_enabled_plugins', + lambda: ['printers', 'computers']) + resp = client.get('/api/plugins/enabled') + assert resp.status_code == 200, resp.get_json() + data = resp.get_json()['data'] + assert 'printers' in data + assert 'usb' not in data diff --git a/tests/test_core/test_pluginui.py b/tests/test_core/test_pluginui.py new file mode 100644 index 00000000..b2186011 --- /dev/null +++ b/tests/test_core/test_pluginui.py @@ -0,0 +1,144 @@ +"""Tests for the ADR-010 frontend-contribution consumers (/api/pluginui/*). + +Pins the wiring added for the four data-only presentation hooks +(get_settings_cards, get_asset_panels, get_map_overlays, +get_asset_presentation): each endpoint aggregates enabled plugins' +contributions, tags them with the plugin name, and drops disabled plugins. +Also asserts the pilot plugins (warranty, measuringtools) contribute. + +Plugin enabled-state is monkeypatched (not persisted) so these tests do not +mutate the shared instance/plugins.json registry file. +""" + + +def _data(client, path, headers): + response = client.get(path, headers=headers) + assert response.status_code == 200, response.get_json() + payload = response.get_json()['data'] + assert isinstance(payload, list) + return payload + + +# ============================================================================= +# settings-cards +# ============================================================================= + +def test_settings_cards_aggregate_enabled_plugins(app, client, auth_headers, monkeypatch): + """measuringtools contributes its settings card, tagged + position-sorted.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + + cards = _data(client, '/api/pluginui/settings-cards', auth_headers) + plugins = {c.get('plugin') for c in cards} + assert 'measuringtools' in plugins + card = next(c for c in cards if c['plugin'] == 'measuringtools') + assert card['group'] == 'Measuring Tools' + assert card['to'] == '/settings/measuringtooltypes' + positions = [c.get('position', 99) for c in cards] + assert positions == sorted(positions) + + +def test_settings_cards_skip_disabled_plugin(app, client, auth_headers, monkeypatch): + """A disabled measuringtools drops its settings card.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'measuringtools') + + cards = _data(client, '/api/pluginui/settings-cards', auth_headers) + assert 'measuringtools' not in {c.get('plugin') for c in cards} + + +# ============================================================================= +# asset-panels +# ============================================================================= + +def _make_asset(db): + from shopdb.core.models import Asset, AssetType + atype = AssetType.query.filter_by(assettype='computer').first() + if not atype: + atype = AssetType(assettype='computer', pluginname='computer', + tablename='computer', description='c') + db.session.add(atype) + db.session.commit() + asset = Asset(assetnumber='AST-PANEL01', assettypeid=atype.assettypeid, + isactive=True) + db.session.add(asset) + db.session.commit() + return asset.assetid + + +def test_asset_panels_require_assetid(client, auth_headers): + """asset-panels without assetid is a 400.""" + response = client.get('/api/pluginui/asset-panels', headers=auth_headers) + assert response.status_code == 400 + + +def test_asset_panels_match_asset_type(app, client, db, auth_headers, monkeypatch): + """Warranty's ['*'] panel matches any asset; tagged with its plugin.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + assetid = _make_asset(db) + + panels = _data(client, f'/api/pluginui/asset-panels?assetid={assetid}', auth_headers) + warranty = next((p for p in panels if p.get('plugin') == 'warranty'), None) + assert warranty is not None, 'warranty asset panel missing' + assert warranty['id'] == 'warranty' + assert warranty['endpoint'] == '/api/warranty/asset/{assetid}' + assert warranty['render'] in ('keyvalue', 'table', 'badge') + + +def test_asset_panels_skip_disabled_plugin(app, client, db, auth_headers, monkeypatch): + """A disabled warranty drops its asset panel.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'warranty') + assetid = _make_asset(db) + + panels = _data(client, f'/api/pluginui/asset-panels?assetid={assetid}', auth_headers) + assert 'warranty' not in {p.get('plugin') for p in panels} + + +# ============================================================================= +# map-overlays +# ============================================================================= + +def test_map_overlays_aggregate_enabled_plugins(app, client, auth_headers, monkeypatch): + """measuringtools contributes its calibration-due overlay.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + + overlays = _data(client, '/api/pluginui/map-overlays', auth_headers) + overlay = next((o for o in overlays if o.get('plugin') == 'measuringtools'), None) + assert overlay is not None + assert overlay['id'] == 'calibration-due' + assert overlay['legend'] is True + + +def test_map_overlays_skip_disabled_plugin(app, client, auth_headers, monkeypatch): + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'measuringtools') + + overlays = _data(client, '/api/pluginui/map-overlays', auth_headers) + assert 'measuringtools' not in {o.get('plugin') for o in overlays} + + +# ============================================================================= +# asset-presentation +# ============================================================================= + +def test_asset_presentation_aggregate_enabled_plugins(app, client, auth_headers, monkeypatch): + """measuringtools declares presentation for its measuring_tool type.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + + entries = _data(client, '/api/pluginui/asset-presentation', auth_headers) + entry = next((e for e in entries if e.get('assettype') == 'measuring_tool'), None) + assert entry is not None + assert entry['plugin'] == 'measuringtools' + assert entry['route'] == '/measuringtools/by-asset/{assetid}' + + +def test_asset_presentation_skip_disabled_plugin(app, client, auth_headers, monkeypatch): + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'measuringtools') + + entries = _data(client, '/api/pluginui/asset-presentation', auth_headers) + assert 'measuring_tool' not in {e.get('assettype') for e in entries} diff --git a/tests/test_core/test_relationship_directional.py b/tests/test_core/test_relationship_directional.py new file mode 100644 index 00000000..ce59deb6 --- /dev/null +++ b/tests/test_core/test_relationship_directional.py @@ -0,0 +1,106 @@ +"""Relationship-type directionality: CRUD carries isdirectional and the +per-asset relationships endpoint tags each row with its type directionality. + +The frontend uses isdirectional to collapse symmetric-type rows (Dualpath and +other physical links) into one direction-blind card entry, and to render +directional types (controls, partof...) with source->target phrasing. That +dedupe is view logic verified live; here we lock the backend surface it needs. +""" + +from shopdb.extensions import db +from shopdb.core.models import Asset, AssetType +from shopdb.core.models.relationship import RelationshipType, AssetRelationship + + +def _make_asset(assetnumber, typeid): + asset = Asset(assetnumber=assetnumber, assettypeid=typeid) + db.session.add(asset) + db.session.flush() + return asset + + +# --------------------------------------------------------------------------- +# Relationship type CRUD carries isdirectional +# --------------------------------------------------------------------------- + +def test_create_type_defaults_directional(client, db, auth_headers): + resp = client.post('/api/assets/relationshiptypes', + json={'relationshiptype': 'Backup For'}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + assert resp.get_json()['data']['isdirectional'] is True + + +def test_create_symmetric_type(client, db, auth_headers): + resp = client.post('/api/assets/relationshiptypes', + json={'relationshiptype': 'Dualpath', + 'isdirectional': False}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + assert resp.get_json()['data']['isdirectional'] is False + + +def test_update_toggles_directional(client, db, auth_headers): + typeid = client.post('/api/assets/relationshiptypes', + json={'relationshiptype': 'USB'}, + headers=auth_headers).get_json()['data']['relationshiptypeid'] + resp = client.put(f'/api/assets/relationshiptypes/{typeid}', + json={'isdirectional': False}, headers=auth_headers) + assert resp.status_code == 200 + assert resp.get_json()['data']['isdirectional'] is False + + +def test_list_includes_directional(client, db, auth_headers): + client.post('/api/assets/relationshiptypes', + json={'relationshiptype': 'WiFi', 'isdirectional': False}, + headers=auth_headers) + resp = client.get('/api/assets/relationshiptypes') + assert resp.status_code == 200 + row = next(t for t in resp.get_json()['data'] + if t['relationshiptype'] == 'WiFi') + assert row['isdirectional'] is False + + +# --------------------------------------------------------------------------- +# Per-asset relationships endpoint tags each row with isdirectional +# --------------------------------------------------------------------------- + +def test_relationships_row_carries_isdirectional(client, db, auth_headers): + atype = AssetType(assettype='machine') + db.session.add(atype) + db.session.flush() + + symmetric = RelationshipType(relationshiptype='Dualpath', isdirectional=False) + directional = RelationshipType(relationshiptype='controls', isdirectional=True) + db.session.add_all([symmetric, directional]) + db.session.flush() + + a = _make_asset('M-A', atype.assettypeid) + b = _make_asset('M-B', atype.assettypeid) + + # symmetric link stored in both directions (legacy shape) + db.session.add(AssetRelationship(sourceassetid=a.assetid, targetassetid=b.assetid, + relationshiptypeid=symmetric.relationshiptypeid)) + db.session.add(AssetRelationship(sourceassetid=b.assetid, targetassetid=a.assetid, + relationshiptypeid=symmetric.relationshiptypeid)) + # directional link a controls b + db.session.add(AssetRelationship(sourceassetid=a.assetid, targetassetid=b.assetid, + relationshiptypeid=directional.relationshiptypeid)) + db.session.commit() + + resp = client.get(f'/api/assets/{a.assetid}/relationships') + assert resp.status_code == 200 + data = resp.get_json()['data'] + + for row in data['outgoing']: + assert 'isdirectional' in row + for row in data['incoming']: + assert 'isdirectional' in row + + # symmetric type reports False both directions; directional reports True + sym_rows = [r for r in data['outgoing'] + data['incoming'] + if r['relationshiptypename'] == 'Dualpath'] + assert sym_rows and all(r['isdirectional'] is False for r in sym_rows) + ctrl_rows = [r for r in data['outgoing'] + if r['relationshiptypename'] == 'controls'] + assert ctrl_rows and all(r['isdirectional'] is True for r in ctrl_rows) diff --git a/tests/test_core/test_relationship_propagation.py b/tests/test_core/test_relationship_propagation.py new file mode 100644 index 00000000..c9611cbe --- /dev/null +++ b/tests/test_core/test_relationship_propagation.py @@ -0,0 +1,295 @@ +"""Relationship propagation across symmetric rails (ADR-001, wired). + +A Dualpath pair is one physical dual-bay machine with a single controller PC +and a bay-selector switch, so BOTH bay records must carry the controls link to +that PC. Creating PC controls bayA where bayA has a Dualpath partner bayB +auto-creates PC controls bayB. The propagation set is M:N +(relationshiptypepropagations): controls propagates through Dualpath +(symmetric, consumed) and through partof (directional, declared but inert). +""" + +from shopdb.extensions import db +from shopdb.core.models import Asset, AssetType +from shopdb.core.models.relationship import ( + RelationshipType, + RelationshipTypePropagation, + AssetRelationship, +) + + +def _make_asset(assetnumber, typeid): + asset = Asset(assetnumber=assetnumber, assettypeid=typeid) + db.session.add(asset) + db.session.flush() + return asset + + +def _setup_types(): + atype = AssetType(assettype='machine') + db.session.add(atype) + db.session.flush() + + controls = RelationshipType(relationshiptype='controls', isdirectional=True) + dualpath = RelationshipType(relationshiptype='Dualpath', isdirectional=False) + partof = RelationshipType(relationshiptype='partof', isdirectional=True) + db.session.add_all([controls, dualpath, partof]) + db.session.flush() + return atype, controls, dualpath, partof + + +def _rel_exists(sourceid, targetid, typeid): + return AssetRelationship.query.filter_by( + sourceassetid=sourceid, targetassetid=targetid, relationshiptypeid=typeid + ).first() is not None + + +# --------------------------------------------------------------------------- +# Symmetric through-type propagates (controls fans across a Dualpath pair) +# --------------------------------------------------------------------------- + +def test_controls_propagates_across_dualpath_pair(client, db, auth_headers): + atype, controls, dualpath, partof = _setup_types() + db.session.add(RelationshipTypePropagation( + relationshiptypeid=controls.relationshiptypeid, + throughtypeid=dualpath.relationshiptypeid)) + + pc = _make_asset('PC-1', atype.assettypeid) + baya = _make_asset('BAY-A', atype.assettypeid) + bayb = _make_asset('BAY-B', atype.assettypeid) + + # the two bays are Dualpath partners (one physical machine) + db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid, + relationshiptypeid=dualpath.relationshiptypeid)) + db.session.commit() + + resp = client.post('/api/assets/relationships', headers=auth_headers, json={ + 'sourceassetid': pc.assetid, + 'targetassetid': baya.assetid, + 'relationshiptypeid': controls.relationshiptypeid, + }) + assert resp.status_code == 201, resp.get_json() + assert resp.get_json()['data']['propagatedcount'] == 1 + + # PC now controls BOTH bays + assert _rel_exists(pc.assetid, baya.assetid, controls.relationshiptypeid) + assert _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid) + + +def test_propagation_finds_partner_stored_either_direction(client, db, auth_headers): + # Dualpath row stored partner->target; propagation must still catch it. + atype, controls, dualpath, partof = _setup_types() + db.session.add(RelationshipTypePropagation( + relationshiptypeid=controls.relationshiptypeid, + throughtypeid=dualpath.relationshiptypeid)) + + pc = _make_asset('PC-2', atype.assettypeid) + baya = _make_asset('BAY-A2', atype.assettypeid) + bayb = _make_asset('BAY-B2', atype.assettypeid) + # note: bayb is the SOURCE of the Dualpath row, target is baya + db.session.add(AssetRelationship(sourceassetid=bayb.assetid, targetassetid=baya.assetid, + relationshiptypeid=dualpath.relationshiptypeid)) + db.session.commit() + + resp = client.post('/api/assets/relationships', headers=auth_headers, json={ + 'sourceassetid': pc.assetid, + 'targetassetid': baya.assetid, + 'relationshiptypeid': controls.relationshiptypeid, + }) + assert resp.status_code == 201 + assert _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid) + + +def test_controls_propagates_when_bay_is_source(client, db, auth_headers): + # Matches real dev data: controls is stored bay -> PC (source is the bay). + # The bay's Dualpath partner must also get a controls -> PC row. + atype, controls, dualpath, partof = _setup_types() + db.session.add(RelationshipTypePropagation( + relationshiptypeid=controls.relationshiptypeid, + throughtypeid=dualpath.relationshiptypeid)) + + pc = _make_asset('PC-S', atype.assettypeid) + baya = _make_asset('BAY-AS', atype.assettypeid) + bayb = _make_asset('BAY-BS', atype.assettypeid) + db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid, + relationshiptypeid=dualpath.relationshiptypeid)) + db.session.commit() + + resp = client.post('/api/assets/relationships', headers=auth_headers, json={ + 'sourceassetid': baya.assetid, + 'targetassetid': pc.assetid, + 'relationshiptypeid': controls.relationshiptypeid, + }) + assert resp.status_code == 201, resp.get_json() + assert resp.get_json()['data']['propagatedcount'] == 1 + # partner bay now also controls the PC, same direction (bay -> PC) + assert _rel_exists(bayb.assetid, pc.assetid, controls.relationshiptypeid) + + +# --------------------------------------------------------------------------- +# Idempotent: pre-existing propagated row is not duplicated +# --------------------------------------------------------------------------- + +def test_propagation_idempotent(client, db, auth_headers): + atype, controls, dualpath, partof = _setup_types() + db.session.add(RelationshipTypePropagation( + relationshiptypeid=controls.relationshiptypeid, + throughtypeid=dualpath.relationshiptypeid)) + + pc = _make_asset('PC-3', atype.assettypeid) + baya = _make_asset('BAY-A3', atype.assettypeid) + bayb = _make_asset('BAY-B3', atype.assettypeid) + db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid, + relationshiptypeid=dualpath.relationshiptypeid)) + # the mirrored row already exists + db.session.add(AssetRelationship(sourceassetid=pc.assetid, targetassetid=bayb.assetid, + relationshiptypeid=controls.relationshiptypeid)) + db.session.commit() + + resp = client.post('/api/assets/relationships', headers=auth_headers, json={ + 'sourceassetid': pc.assetid, + 'targetassetid': baya.assetid, + 'relationshiptypeid': controls.relationshiptypeid, + }) + assert resp.status_code == 201 + assert resp.get_json()['data']['propagatedcount'] == 0 + + count = AssetRelationship.query.filter_by( + sourceassetid=pc.assetid, targetassetid=bayb.assetid, + relationshiptypeid=controls.relationshiptypeid).count() + assert count == 1 + + +# --------------------------------------------------------------------------- +# Non-propagating type creates no extras +# --------------------------------------------------------------------------- + +def test_non_propagating_type_no_extras(client, db, auth_headers): + atype, controls, dualpath, partof = _setup_types() + # controls has NO propagation rows here + + pc = _make_asset('PC-4', atype.assettypeid) + baya = _make_asset('BAY-A4', atype.assettypeid) + bayb = _make_asset('BAY-B4', atype.assettypeid) + db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid, + relationshiptypeid=dualpath.relationshiptypeid)) + db.session.commit() + + resp = client.post('/api/assets/relationships', headers=auth_headers, json={ + 'sourceassetid': pc.assetid, + 'targetassetid': baya.assetid, + 'relationshiptypeid': controls.relationshiptypeid, + }) + assert resp.status_code == 201 + assert resp.get_json()['data']['propagatedcount'] == 0 + assert not _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid) + + +# --------------------------------------------------------------------------- +# Directional through-type (partof) does NOT propagate (deferred) +# --------------------------------------------------------------------------- + +def test_directional_through_type_does_not_propagate(client, db, auth_headers): + atype, controls, dualpath, partof = _setup_types() + # controls propagates only through partof, which is directional -> inert + db.session.add(RelationshipTypePropagation( + relationshiptypeid=controls.relationshiptypeid, + throughtypeid=partof.relationshiptypeid)) + + pc = _make_asset('PC-5', atype.assettypeid) + parent = _make_asset('PARENT-5', atype.assettypeid) + child = _make_asset('CHILD-5', atype.assettypeid) + db.session.add(AssetRelationship(sourceassetid=child.assetid, targetassetid=parent.assetid, + relationshiptypeid=partof.relationshiptypeid)) + db.session.commit() + + resp = client.post('/api/assets/relationships', headers=auth_headers, json={ + 'sourceassetid': pc.assetid, + 'targetassetid': parent.assetid, + 'relationshiptypeid': controls.relationshiptypeid, + }) + assert resp.status_code == 201 + assert resp.get_json()['data']['propagatedcount'] == 0 + assert not _rel_exists(pc.assetid, child.assetid, controls.relationshiptypeid) + + +# --------------------------------------------------------------------------- +# CLI backfill creates missing rows and is idempotent run twice +# --------------------------------------------------------------------------- + +def test_cli_backfill_propagates_and_is_idempotent(client, db, auth_headers, runner): + atype, controls, dualpath, partof = _setup_types() + db.session.add(RelationshipTypePropagation( + relationshiptypeid=controls.relationshiptypeid, + throughtypeid=dualpath.relationshiptypeid)) + + pc = _make_asset('PC-6', atype.assettypeid) + baya = _make_asset('BAY-A6', atype.assettypeid) + bayb = _make_asset('BAY-B6', atype.assettypeid) + db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid, + relationshiptypeid=dualpath.relationshiptypeid)) + # legacy-import shape: controls exists only on the primary bay + db.session.add(AssetRelationship(sourceassetid=pc.assetid, targetassetid=baya.assetid, + relationshiptypeid=controls.relationshiptypeid)) + db.session.commit() + + assert not _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid) + + result = runner.invoke(args=['relationships', 'propagate']) + assert result.exit_code == 0, result.output + assert 'Propagated 1' in result.output + assert _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid) + + # second run creates nothing more + result2 = runner.invoke(args=['relationships', 'propagate']) + assert result2.exit_code == 0, result2.output + assert 'Propagated 0' in result2.output + count = AssetRelationship.query.filter_by( + sourceassetid=pc.assetid, targetassetid=bayb.assetid, + relationshiptypeid=controls.relationshiptypeid).count() + assert count == 1 + + +# --------------------------------------------------------------------------- +# CLI fix-controls-direction flips reversed legacy rows to PC -> machine +# --------------------------------------------------------------------------- + +def test_cli_fix_controls_direction(client, db, auth_headers, runner): + atype, controls, dualpath, partof = _setup_types() + ctype = AssetType(assettype='computer') + db.session.add(ctype) + db.session.flush() + + pc = _make_asset('PC-7', ctype.assettypeid) + pc2 = _make_asset('PC-8', ctype.assettypeid) + bay = _make_asset('BAY-7', atype.assettypeid) + bay2 = _make_asset('BAY-8', atype.assettypeid) + + # reversed legacy row: machine -> PC + db.session.add(AssetRelationship(sourceassetid=bay.assetid, targetassetid=pc.assetid, + relationshiptypeid=controls.relationshiptypeid)) + # reversed row whose flip already exists -> reversed one gets deactivated + db.session.add(AssetRelationship(sourceassetid=bay2.assetid, targetassetid=pc2.assetid, + relationshiptypeid=controls.relationshiptypeid)) + db.session.add(AssetRelationship(sourceassetid=pc2.assetid, targetassetid=bay2.assetid, + relationshiptypeid=controls.relationshiptypeid)) + db.session.commit() + + result = runner.invoke(args=['relationships', 'fix-controls-direction']) + assert result.exit_code == 0, result.output + assert 'Flipped 1' in result.output + assert '1 reversed duplicate(s) deactivated' in result.output + + # first row now reads PC -> machine + assert _rel_exists(pc.assetid, bay.assetid, controls.relationshiptypeid) + assert not _rel_exists(bay.assetid, pc.assetid, controls.relationshiptypeid) + # duplicate pair: canonical row stays active, reversed one deactivated + rev = AssetRelationship.query.filter_by( + sourceassetid=bay2.assetid, targetassetid=pc2.assetid, + relationshiptypeid=controls.relationshiptypeid).first() + assert rev is not None and rev.isactive is False + + # idempotent: second run finds nothing + result2 = runner.invoke(args=['relationships', 'fix-controls-direction']) + assert result2.exit_code == 0, result2.output + assert 'Flipped 0' in result2.output + assert '0 reversed duplicate(s) deactivated' in result2.output diff --git a/tests/test_core/test_reports_hook.py b/tests/test_core/test_reports_hook.py new file mode 100644 index 00000000..b2c4ab2d --- /dev/null +++ b/tests/test_core/test_reports_hook.py @@ -0,0 +1,55 @@ +"""Tests for the get_reports hook consumer (GET /api/reports). + +Pins the wiring added for BasePlugin.get_reports: the reports list merges +enabled plugins' report cards after the static core reports and skips disabled +ones. Also guards the removed legacy warranty-status core report. + +Plugin enabled-state is monkeypatched (not persisted) so these tests do not +mutate the shared instance/plugins.json registry file. +""" + + +def _report_ids(client, headers): + response = client.get('/api/reports', headers=headers) + assert response.status_code == 200, response.get_json() + reports = response.get_json()['data']['reports'] + assert isinstance(reports, list) + return reports, {r['id'] for r in reports} + + +def test_reports_include_core_reports(client, auth_headers): + """The static core reports are always listed.""" + _, ids = _report_ids(client, auth_headers) + assert 'machines-by-type' in ids + assert 'pc-relationships' in ids + + +def test_legacy_warranty_status_report_absent(client, auth_headers): + """The dead warranty-status core report was removed.""" + _, ids = _report_ids(client, auth_headers) + assert 'warranty-status' not in ids + + +def test_enabled_plugin_reports_are_merged(app, client, auth_headers, monkeypatch): + """Enabled plugins implementing get_reports contribute their cards.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + + reports, ids = _report_ids(client, auth_headers) + # warranty + printers implement get_reports + assert 'warranty' in ids + assert 'toner' in ids + # merged cards carry their originating plugin name + warranty_card = next(r for r in reports if r['id'] == 'warranty') + assert warranty_card['plugin'] == 'warranty' + assert warranty_card['route'] == '/reports/warranty' + + +def test_disabled_plugin_reports_drop_out(app, client, auth_headers, monkeypatch): + """A disabled plugin's report cards disappear from the list.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'warranty') + + _, ids = _report_ids(client, auth_headers) + assert 'warranty' not in ids + assert 'toner' in ids # printers still enabled diff --git a/tests/test_core/test_search_customfields.py b/tests/test_core/test_search_customfields.py new file mode 100644 index 00000000..24e3909e --- /dev/null +++ b/tests/test_core/test_search_customfields.py @@ -0,0 +1,114 @@ +"""Global search matches custom-field VALUES for fields flagged searchable. + +Pins: a searchable custom field's value routes to its asset; a non-searchable +field is NOT matched; disabling the asset's search domain excludes the hit; an +asset matched by BOTH its assetnumber and a custom field appears once; an +inactive field is excluded. + +Search by a distinctive value string so only the custom-field domain can match +(the value is not any built-in identifier). +""" + +from shopdb.core.api.settings import invalidate_settings_cache + + +def _seed_computer(client, db, auth_headers, assetnumber): + """Create a computer asset and return its assetid.""" + from shopdb.core.models import AssetType, Asset + if not AssetType.query.filter_by(assettype='computer').first(): + db.session.add(AssetType(assettype='computer', pluginname='computer', + tablename='computer', description='c')) + db.session.commit() + resp = client.post('/api/computers', + json={'assetnumber': assetnumber}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + asset = Asset.query.filter_by(assetnumber=assetnumber).first() + return asset.assetid + + +def _seed_field(db, label, searchable=True, isactive=True): + """Create a computer-scoped custom field and return its fieldid.""" + from shopdb.core.models import AssetType, CustomField + assettype = AssetType.query.filter_by(assettype='computer').first() + field = CustomField(assettypeid=assettype.assettypeid, + fieldkey=label.replace(' ', '').lower(), + label=label, datatype='text', + searchable=searchable, isactive=isactive) + db.session.add(field) + db.session.commit() + return field.fieldid + + +def _set_value(db, fieldid, assetid, value): + from shopdb.core.models import CustomFieldValue + db.session.add(CustomFieldValue(fieldid=fieldid, assetid=assetid, value=value)) + db.session.commit() + + +def _hits(client, auth_headers, term): + resp = client.get(f'/api/search?q={term}', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + return resp.get_json()['data']['results'] + + +def test_searchable_field_value_matches_and_routes(client, db, auth_headers): + """A searchable custom-field value matches and routes to the asset page.""" + assetid = _seed_computer(client, db, auth_headers, 'AST-CF01') + fieldid = _seed_field(db, 'Tool Crib Number', searchable=True) + _set_value(db, fieldid, assetid, 'cribval01') + + hits = [r for r in _hits(client, auth_headers, 'cribval01') + if r['type'] == 'computer'] + assert len(hits) == 1 + assert hits[0]['url'].startswith('/pcs/') + assert hits[0]['subtitle'] == 'Tool Crib Number' + + +def test_non_searchable_field_value_does_not_match(client, db, auth_headers): + """A value on a NON-searchable field is not matched by global search.""" + assetid = _seed_computer(client, db, auth_headers, 'AST-CF02') + fieldid = _seed_field(db, 'Internal Note', searchable=False) + _set_value(db, fieldid, assetid, 'cribval02') + + assert _hits(client, auth_headers, 'cribval02') == [] + + +def test_disabled_domain_excludes_customfield_hit(client, db, auth_headers): + """search_computer_enabled=false hides a custom-field hit on a computer.""" + from shopdb.core.models import Setting + assetid = _seed_computer(client, db, auth_headers, 'AST-CF03') + fieldid = _seed_field(db, 'Bench Tag', searchable=True) + _set_value(db, fieldid, assetid, 'cribval03') + + # Sanity: enabled by default. + assert [r for r in _hits(client, auth_headers, 'cribval03') + if r['type'] == 'computer'] + + Setting.set('search_computer_enabled', False, valuetype='boolean', + category='search') + invalidate_settings_cache() + assert [r for r in _hits(client, auth_headers, 'cribval03') + if r['type'] == 'computer'] == [] + + +def test_dedupe_assetnumber_and_customfield(client, db, auth_headers): + """An asset matched by both assetnumber and a custom field appears once.""" + assetid = _seed_computer(client, db, auth_headers, 'cribval04') + fieldid = _seed_field(db, 'Crib Ref', searchable=True) + _set_value(db, fieldid, assetid, 'cribval04') + + hits = [r for r in _hits(client, auth_headers, 'cribval04') + if r['type'] == 'computer'] + assert len(hits) == 1 + # The higher-relevance built-in assetnumber match wins the dedupe. + assert hits[0]['relevance'] == 100 + + +def test_inactive_field_excluded(client, db, auth_headers): + """A value on an inactive (searchable) field is not matched.""" + assetid = _seed_computer(client, db, auth_headers, 'AST-CF05') + fieldid = _seed_field(db, 'Retired Tag', searchable=True, isactive=False) + _set_value(db, fieldid, assetid, 'cribval05') + + assert _hits(client, auth_headers, 'cribval05') == [] diff --git a/tests/test_core/test_search_disabled.py b/tests/test_core/test_search_disabled.py new file mode 100644 index 00000000..fea12d9e --- /dev/null +++ b/tests/test_core/test_search_disabled.py @@ -0,0 +1,43 @@ +"""Global search must exclude rows from disabled plugins (search.py _require_enabled). + +Pins the data-exposure property: disabling a plugin removes its plugin-specific +matches (e.g. hostname) from /api/search, mirroring the plugin being absent. + +Search by HOSTNAME (distinct from assetnumber) so only the gated hostname domain +can match - the core asset-number domain is intentionally always-on. +""" + + +def _seed_computer(client, db, auth_headers, assetnumber, hostname): + from shopdb.core.models import AssetType + if not AssetType.query.filter_by(assettype='computer').first(): + db.session.add(AssetType(assettype='computer', pluginname='computer', + tablename='computer', description='c')) + db.session.commit() + resp = client.post('/api/computers', + json={'assetnumber': assetnumber, 'hostname': hostname}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + + +def _computer_hits(client, auth_headers, term): + resp = client.get(f'/api/search?q={term}', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + results = resp.get_json()['data']['results'] + return [r for r in results if r.get('type') == 'computer'] + + +def test_search_includes_enabled_plugin(app, client, db, auth_headers, monkeypatch): + """An enabled plugin's hostname matches appear in search.""" + _seed_computer(client, db, auth_headers, 'AST-SD01', 'hostsearch01') + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + assert _computer_hits(client, auth_headers, 'hostsearch01') + + +def test_search_excludes_disabled_plugin(app, client, db, auth_headers, monkeypatch): + """A disabled plugin's hostname matches drop out of search results.""" + _seed_computer(client, db, auth_headers, 'AST-SD02', 'hostsearch02') + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'computers') + assert _computer_hits(client, auth_headers, 'hostsearch02') == [] diff --git a/tests/test_core/test_search_integrations.py b/tests/test_core/test_search_integrations.py new file mode 100644 index 00000000..4da87cc2 --- /dev/null +++ b/tests/test_core/test_search_integrations.py @@ -0,0 +1,105 @@ +"""Settings-driven search integrations (ServiceNow + employee-ID pattern). + +The ServiceNow ticket prefixes, the ServiceNow search URL/enabled flag, and the +employee-ID recognition pattern all ship as GE defaults but are configurable +per-site via Settings. These tests pin that the search endpoint honors those +settings and that a bad employeeid_pattern regex falls back rather than 500ing. + +Settings are cached, so every case seeds its rows and calls +invalidate_settings_cache() before exercising search. +""" + +from shopdb.core.models import Setting +from shopdb.core.api.settings import invalidate_settings_cache +from shopdb.core.api.search import _get_search_integrations, _classify_query + + +def _redirect(client, auth_headers, term): + resp = client.get(f'/api/search?q={term}', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + return resp.get_json()['data'].get('redirect') + + +def test_default_prefix_classifies_servicenow(client, db, auth_headers): + """With no override, a GE-prefixed ticket redirects to ServiceNow.""" + invalidate_settings_cache() + redirect = _redirect(client, auth_headers, 'GEINC123') + assert redirect is not None + assert redirect['type'] == 'servicenow' + + +def test_custom_prefix_classifies_servicenow(client, db, auth_headers): + """A site-configured prefix classifies its tickets as ServiceNow.""" + Setting.set('servicenow_ticket_prefixes', 'ACMEINC', valuetype='string', + category='integrations') + invalidate_settings_cache() + redirect = _redirect(client, auth_headers, 'ACMEINC123') + assert redirect is not None + assert redirect['type'] == 'servicenow' + # Ticket is url-encoded into the {ticket} placeholder. + assert 'ACMEINC123' in redirect['url'] + + +def test_custom_prefix_ignores_default_prefix(client, db, auth_headers): + """Overriding the prefixes drops the built-in GE prefixes.""" + Setting.set('servicenow_ticket_prefixes', 'ACMEINC', valuetype='string', + category='integrations') + invalidate_settings_cache() + redirect = _redirect(client, auth_headers, 'GEINC123') + # GEINC is no longer a configured prefix, so no ServiceNow redirect. + assert redirect is None or redirect.get('type') != 'servicenow' + + +def test_disabled_servicenow_yields_no_redirect(client, db, auth_headers): + """servicenow_enabled=false suppresses the ServiceNow redirect entirely.""" + Setting.set('servicenow_enabled', False, valuetype='boolean', + category='integrations') + invalidate_settings_cache() + redirect = _redirect(client, auth_headers, 'GEINC123') + assert redirect is None or redirect.get('type') != 'servicenow' + + +def test_blank_search_url_yields_no_redirect(client, db, auth_headers): + """An empty servicenow_search_url disables the redirect.""" + Setting.set('servicenow_search_url', '', valuetype='string', + category='integrations') + invalidate_settings_cache() + redirect = _redirect(client, auth_headers, 'GEINC123') + assert redirect is None or redirect.get('type') != 'servicenow' + + +def test_custom_employeeid_pattern_recognizes_six_digits(client, db, auth_headers): + """A ^\\d{6}$ pattern makes six-digit IDs classify as employee queries.""" + Setting.set('employeeid_pattern', r'^\d{6}$', valuetype='string', + category='site') + invalidate_settings_cache() + integrations = _get_search_integrations() + assert _classify_query('123456', integrations)['is_sso'] is True + # Anchored pattern rejects the old nine-digit shape. + assert _classify_query('123456789', integrations)['is_sso'] is False + + +def test_default_employeeid_pattern_recognizes_nine_digits(client, db, auth_headers): + """The shipped default recognizes nine-digit SSO IDs.""" + invalidate_settings_cache() + integrations = _get_search_integrations() + assert _classify_query('123456789', integrations)['is_sso'] is True + assert _classify_query('123456', integrations)['is_sso'] is False + + +def test_invalid_employeeid_pattern_falls_back(client, db, auth_headers): + """A malformed regex falls back to nine-digit matching without erroring.""" + Setting.set('employeeid_pattern', '[', valuetype='string', category='site') + invalidate_settings_cache() + # Must not raise on compile of the bad pattern. + integrations = _get_search_integrations() + assert _classify_query('123456789', integrations)['is_sso'] is True + assert _classify_query('12345', integrations)['is_sso'] is False + + +def test_invalid_pattern_search_does_not_500(client, db, auth_headers): + """End-to-end: a bad employeeid_pattern must not break the search endpoint.""" + Setting.set('employeeid_pattern', '(', valuetype='string', category='site') + invalidate_settings_cache() + resp = client.get('/api/search?q=hello', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() diff --git a/tests/test_core/test_search_settings.py b/tests/test_core/test_search_settings.py new file mode 100644 index 00000000..2514ab28 --- /dev/null +++ b/tests/test_core/test_search_settings.py @@ -0,0 +1,44 @@ +"""Per-domain global-search toggles (search__enabled). + +A content type can be hidden from search independently of whether its plugin is +enabled, by setting search__enabled = false. +""" + +from shopdb.core.api.settings import SEARCH_DOMAINS + + +def _kb_types(client, auth_headers, term): + resp = client.get(f'/api/search?q={term}', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + return [r for r in resp.get_json()['data']['results'] + if r.get('type') == 'knowledgebase'] + + +def test_domain_included_by_default(client, db, auth_headers): + """With no toggle set, the knowledgebase domain appears in search.""" + client.post('/api/knowledgebase', + json={'shortdescription': 'searchtoggle alpha', + 'linkurl': 'https://kb.example/a'}, + headers=auth_headers) + assert _kb_types(client, auth_headers, 'searchtoggle') + + +def test_disabled_domain_excluded(client, db, auth_headers): + """search_knowledgebase_enabled=false hides KB results.""" + from shopdb.core.models import Setting + client.post('/api/knowledgebase', + json={'shortdescription': 'searchtoggle beta', + 'linkurl': 'https://kb.example/b'}, + headers=auth_headers) + Setting.set('search_knowledgebase_enabled', False, valuetype='boolean', + category='search') + assert _kb_types(client, auth_headers, 'searchtoggle') == [] + + +def test_search_seed_creates_domain_keys(client, db, auth_headers): + """Seeding produces one boolean setting per search domain.""" + resp = client.post('/api/settings/seed', headers=auth_headers) + assert resp.status_code == 200 + from shopdb.core.models import Setting + keys = {s.key for s in Setting.query.filter_by(category='search').all()} + assert {f'search_{k}_enabled' for k in SEARCH_DOMAINS} <= keys diff --git a/tests/test_core/test_setting_model.py b/tests/test_core/test_setting_model.py new file mode 100644 index 00000000..4c53e3e5 --- /dev/null +++ b/tests/test_core/test_setting_model.py @@ -0,0 +1,56 @@ +"""Tests for the Setting key-value model, incl. the create-race recovery.""" + +from sqlalchemy.exc import IntegrityError + +from shopdb.core.models import Setting +from shopdb.extensions import db as _db + + +def test_set_creates_then_updates(db): + """set() creates a missing key, then updates the existing row in place.""" + created = Setting.set('greeting', 'hello', category='site') + assert created.settingid is not None + assert Setting.get('greeting') == 'hello' + + Setting.set('greeting', 'goodbye') + assert Setting.get('greeting') == 'goodbye' + + # Only one row for the key (update, not a second insert). + assert Setting.query.filter_by(key='greeting').count() == 1 + + +def test_set_boolean_roundtrip(db): + """Booleans store as canonical strings and read back typed.""" + Setting.set('flag', True, valuetype='boolean') + assert Setting.get('flag') is True + Setting.set('flag', False) + assert Setting.get('flag') is False + + +def test_set_recovers_from_create_race(db, monkeypatch): + """A concurrent insert of the same key makes our commit raise IntegrityError; + set() rolls back, re-fetches the winner's row, and applies our value.""" + real_commit = _db.session.commit + calls = {'n': 0} + + def flaky_commit(): + calls['n'] += 1 + if calls['n'] == 1: + # Drop our own pending insert, then persist the "winner" row exactly + # as a racing transaction would have, and simulate the unique-key + # violation our losing INSERT would raise. + _db.session.rollback() + winner = Setting(key='racekey', value='winner', valuetype='string') + _db.session.add(winner) + real_commit() + raise IntegrityError('INSERT INTO settings', {}, Exception('duplicate key')) + return real_commit() + + monkeypatch.setattr(_db.session, 'commit', flaky_commit) + + result = Setting.set('racekey', 'mine') + + # Recovery path ran: the row exists once and carries our value. + assert result is not None + assert Setting.get('racekey') == 'mine' + assert Setting.query.filter_by(key='racekey').count() == 1 diff --git a/tests/test_core/test_settings_branding.py b/tests/test_core/test_settings_branding.py new file mode 100644 index 00000000..274f1f1f --- /dev/null +++ b/tests/test_core/test_settings_branding.py @@ -0,0 +1,192 @@ +"""Tests for branding-logo upload and settings-secret masking. + +Covers WP1 of the multi-site distribution work: the branding-logo upload +endpoint (admin-only, kind/extension validation, round-trip), the public +serve route, the secret-masking guarantee on the settings list, and the +canonical new-settings defaults. +""" + +import io + +from shopdb.core.api.settings import ( + build_default_settings, + BRANDING_KIND_SETTINGS, +) + + +def _defaults_by_key(): + return {d['key']: d for d in build_default_settings()} + + +def test_anon_settings_list_excludes_nonpublic(client, db): + """Anonymous GET /api/settings returns only the public allowlist - secret + AND non-secret internal keys are absent, so plaintext can never leak.""" + from shopdb.core.models import Setting + + Setting.set('smtp_password', 'supersecret', valuetype='string', category='email') + Setting.set('zabbix_token', 'tok-abc-123', valuetype='string', category='integrations') + Setting.set('warranty_dell_clientsecret', 'shh', valuetype='string', category='integrations') + # A public key stays visible for contrast. + Setting.set('facility_name', 'Test Plant', valuetype='string', category='site') + + resp = client.get('/api/settings') + assert resp.status_code == 200, resp.get_json() + by_key = {s['key']: s['value'] for s in resp.get_json()['data']} + + # Non-public keys are not enumerable at all (stronger than masking). + assert 'smtp_password' not in by_key + assert 'zabbix_token' not in by_key + assert 'warranty_dell_clientsecret' not in by_key + # Plaintext secrets must not leak anywhere in the response body. + assert 'supersecret' not in resp.get_data(as_text=True) + assert 'tok-abc-123' not in resp.get_data(as_text=True) + # A public key passes through untouched. + assert by_key['facility_name'] == 'Test Plant' + + +def test_branding_upload_forbidden_for_non_admin(client, db, member_headers): + """A role-less authenticated user cannot upload a branding logo.""" + data = { + 'kind': 'site', + 'file': (io.BytesIO(b''), 'logo.svg'), + } + resp = client.post('/api/settings/branding-logo', data=data, + content_type='multipart/form-data', headers=member_headers) + assert resp.status_code == 403 + assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN' + + +def test_branding_upload_requires_auth(client, db): + """No token at all cannot reach the upload route.""" + data = {'kind': 'site', 'file': (io.BytesIO(b''), 'logo.svg')} + resp = client.post('/api/settings/branding-logo', data=data, + content_type='multipart/form-data') + assert resp.status_code in (401, 422) + + +def test_branding_upload_round_trip(client, db, auth_headers): + """Admin upload writes the matching setting and the file is then served.""" + payload = b'' + data = {'kind': 'site', 'file': (io.BytesIO(payload), 'mylogo.svg')} + resp = client.post('/api/settings/branding-logo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + body = resp.get_json()['data'] + assert body['key'] == 'site_logo' + assert body['value'] == '/api/settings/branding/logo-site.svg' + + # Setting row now points at the served URL. + from shopdb.core.models import Setting + setting = Setting.query.filter_by(key='site_logo').first() + assert setting is not None + assert setting.value == '/api/settings/branding/logo-site.svg' + + # Public serve route returns the uploaded bytes without auth. + served = client.get('/api/settings/branding/logo-site.svg') + assert served.status_code == 200 + assert served.get_data() == payload + + +def test_branding_upload_favicon_allows_ico(client, db, auth_headers): + """The favicon kind accepts .ico (extra extension beyond map images).""" + data = {'kind': 'favicon', 'file': (io.BytesIO(b'icodata'), 'fav.ico')} + resp = client.post('/api/settings/branding-logo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['key'] == 'site_favicon' + + +def test_branding_upload_rejects_invalid_kind(client, db, auth_headers): + """An unknown kind is a validation error.""" + data = {'kind': 'banner', 'file': (io.BytesIO(b''), 'logo.svg')} + resp = client.post('/api/settings/branding-logo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 400 + assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR' + + +def test_branding_upload_rejects_invalid_extension(client, db, auth_headers): + """A disallowed file extension is rejected.""" + data = {'kind': 'site', 'file': (io.BytesIO(b'MZ...'), 'logo.exe')} + resp = client.post('/api/settings/branding-logo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 400 + assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR' + + +def test_kind_map_covers_expected_kinds(): + """The kind->setting map matches the documented branding kinds.""" + assert BRANDING_KIND_SETTINGS == { + 'site': 'site_logo', + 'qr': 'qr_logo', + 'badge': 'badge_logo', + 'favicon': 'site_favicon', + } + + +def test_defaults_contain_new_branding_keys(): + """build_default_settings seeds every branding key with its default.""" + by_key = _defaults_by_key() + expected = { + 'site_logo': '/ge-aerospace-logo.svg', + 'qr_logo': '/ge-monogram.svg', + 'badge_logo': '/ge-aerospace-logo.svg', + 'site_favicon': '', + 'brand_primary_color': '', + } + for key, value in expected.items(): + assert key in by_key, f'missing default {key}' + assert by_key[key]['value'] == value + assert by_key[key]['category'] == 'branding' + + +def test_defaults_contain_new_integration_keys(): + """build_default_settings seeds the ServiceNow integration keys.""" + by_key = _defaults_by_key() + assert by_key['servicenow_enabled']['value'] == 'true' + assert by_key['servicenow_enabled']['category'] == 'integrations' + assert by_key['servicenow_ticket_prefixes']['value'] == 'GEINC,GECHG,GERIT,GESCT' + assert '{ticket}' in by_key['servicenow_search_url']['value'] + assert '{ticket}' in by_key['servicenow_incident_url']['value'] + assert '{ticket}' in by_key['servicenow_change_url']['value'] + + +def test_defaults_contain_new_site_keys(): + """build_default_settings seeds the employee-id and printer-hostname keys.""" + by_key = _defaults_by_key() + assert by_key['employeeid_pattern']['value'] == r'^\d{9}$' + assert by_key['printer_hostname_template']['value'] == 'Printer-{ip}.printer.geaerospace.net' + assert by_key['contact_email_domain']['value'] == 'geaerospace.com' + assert by_key['contact_email_domain']['category'] == 'site' + + +def test_defaults_contain_label_target_keys(): + """build_default_settings seeds the per-type label targets + label defaults.""" + by_key = _defaults_by_key() + for key in ('qr_target_machine', 'qr_target_computer', + 'qr_target_network_device', 'qr_target_measuring_tool'): + assert key in by_key, f'missing default {key}' + assert by_key[key]['value'] == '' + assert by_key[key]['category'] == 'printing' + assert '{locationcode}' in by_key['qr_target_measuring_tool']['description'] + assert by_key['label_default_style']['value'] == 'card' + assert by_key['label_default_codetype']['value'] == 'qr' + assert by_key['label_default_style']['category'] == 'printing' + assert by_key['label_default_codetype']['category'] == 'printing' + # Per-type default encode mode: machines -> their machine number, + # measuring tools -> inspection location, the rest -> asset page. + assert by_key['label_default_encodes_machine']['value'] == 'assetnumber' + assert by_key['label_default_encodes_measuring_tool']['value'] == 'location' + for assettype in ('computer', 'printer', 'network_device'): + assert by_key[f'label_default_encodes_{assettype}']['value'] == 'assetpage' + assert by_key[f'label_default_encodes_{assettype}']['category'] == 'printing' + + +def test_defaults_changed_facility_and_map(): + """facility_name default is now blank; map blueprints point at the placeholder.""" + by_key = _defaults_by_key() + assert by_key['facility_name']['value'] == '' + assert by_key['map_blueprint_light']['value'] == '/static/images/floorplan-placeholder.svg' + assert by_key['map_blueprint_dark']['value'] == '/static/images/floorplan-placeholder.svg' + # No leftover West Jefferson sitemap references. + assert 'sitemap2025' not in by_key['map_blueprint_light']['value'] diff --git a/tests/test_core/test_slides.py b/tests/test_core/test_slides.py new file mode 100644 index 00000000..1a6a04b3 --- /dev/null +++ b/tests/test_core/test_slides.py @@ -0,0 +1,25 @@ +"""Characterization test for the slides (TV slideshow) plugin feed. + +The slide manager rework moved the public playlist to /api/slides/feed with a +flat (non-enveloped) response shape so the screensaver's existing parser needs +no change. Pin that contract here. +""" + + +def test_slides_feed_shape(client, db): + """GET /api/slides/feed returns a flat playlist for a surface (no auth).""" + resp = client.get('/api/slides/feed?surface=lobby') + assert resp.status_code == 200 + data = resp.get_json() + assert data['success'] is True + assert data['surface'] == 'lobby' + assert isinstance(data['slides'], list) + assert data['basepath'] == '/api/slides/img/lobby/' + assert 'interval' in data + + +def test_slides_feed_unknown_surface_falls_back(client, db): + """An invalid surface name falls back to lobby instead of erroring.""" + resp = client.get('/api/slides/feed?surface=../etc') + assert resp.status_code == 200 + assert resp.get_json()['surface'] == 'lobby' diff --git a/tests/test_core/test_supportteams.py b/tests/test_core/test_supportteams.py new file mode 100644 index 00000000..97e93674 --- /dev/null +++ b/tests/test_core/test_supportteams.py @@ -0,0 +1,234 @@ +"""Support teams + contacts: team/contact CRUD, lookup, delete-guard, import. + +The application/support-team wiring: a team owns ordered contacts, an +application carries one team, and its payload flattens the team name/url + +active contacts so the frontend needs a single call. +""" + +IMPORT_HEADER = {'X-Import-Mode': 'true'} +LEGACY_CREATED = '2020-01-05 08:30:00' +LEGACY_MODIFIED = '2021-06-07T14:15:16' + + +def _import_headers(auth_headers): + merged = dict(auth_headers) + merged.update(IMPORT_HEADER) + return merged + + +def _create_team(client, auth_headers, teamname='Controls', teamurl=None): + return client.post('/api/supportteams', + json={'teamname': teamname, 'teamurl': teamurl}, + headers=auth_headers) + + +# --------------------------------------------------------------------------- +# Team CRUD +# --------------------------------------------------------------------------- + +def test_create_and_get_team(client, db, auth_headers): + """Create a team, then fetch it back with an (empty) contacts list.""" + resp = _create_team(client, auth_headers, 'Controls', + 'https://servicenow.example/group/controls') + assert resp.status_code == 201, resp.get_json() + teamid = resp.get_json()['data']['supportteamid'] + + got = client.get(f'/api/supportteams/{teamid}', headers=auth_headers) + assert got.status_code == 200 + data = got.get_json()['data'] + assert data['teamname'] == 'Controls' + assert data['teamurl'] == 'https://servicenow.example/group/controls' + assert data['contacts'] == [] + + +def test_create_team_requires_teamname(client, db, auth_headers): + resp = client.post('/api/supportteams', json={}, headers=auth_headers) + assert resp.status_code == 400 + + +def test_create_team_duplicate_conflict(client, db, auth_headers): + _create_team(client, auth_headers, 'Controls') + dup = _create_team(client, auth_headers, 'Controls') + assert dup.status_code == 409 + + +def test_update_team(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid'] + resp = client.put(f'/api/supportteams/{teamid}', + json={'teamname': 'Controls Renamed', + 'teamurl': 'https://x.example'}, + headers=auth_headers) + assert resp.status_code == 200 + assert resp.get_json()['data']['teamname'] == 'Controls Renamed' + + +def test_delete_team(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'ToDelete').get_json()['data']['supportteamid'] + resp = client.delete(f'/api/supportteams/{teamid}', headers=auth_headers) + assert resp.status_code == 200 + gone = client.get(f'/api/supportteams/{teamid}', headers=auth_headers) + assert gone.status_code == 404 + + +# --------------------------------------------------------------------------- +# teamname exact-match lookup (import recipe) + active filter +# --------------------------------------------------------------------------- + +def test_teamname_lookup_filter(client, db, auth_headers): + for name in ('Controls', 'ControlsB', 'Networking'): + _create_team(client, auth_headers, name) + listed = client.get('/api/supportteams?teamname=Controls', headers=auth_headers) + assert listed.status_code == 200 + rows = listed.get_json()['data'] + assert len(rows) == 1 + assert rows[0]['teamname'] == 'Controls' + + +def test_list_active_filter_hides_inactive(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'Retired').get_json()['data']['supportteamid'] + client.put(f'/api/supportteams/{teamid}', json={'isactive': False}, + headers=auth_headers) + active = client.get('/api/supportteams', headers=auth_headers) + assert all(t['teamname'] != 'Retired' for t in active.get_json()['data']) + allteams = client.get('/api/supportteams?active=false', headers=auth_headers) + assert any(t['teamname'] == 'Retired' for t in allteams.get_json()['data']) + + +# --------------------------------------------------------------------------- +# Delete-with-applications 409 +# --------------------------------------------------------------------------- + +def test_delete_team_with_applications_conflicts(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'InUse').get_json()['data']['supportteamid'] + appresp = client.post('/api/applications', + json={'appname': 'DependentApp', + 'supportteamid': teamid}, + headers=auth_headers) + assert appresp.status_code == 201, appresp.get_json() + + conflict = client.delete(f'/api/supportteams/{teamid}', headers=auth_headers) + assert conflict.status_code == 409 + # Still there. + assert client.get(f'/api/supportteams/{teamid}', + headers=auth_headers).status_code == 200 + + +# --------------------------------------------------------------------------- +# Nested contact CRUD + ordering +# --------------------------------------------------------------------------- + +def test_contact_crud_and_ordering(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid'] + + # Add two contacts out of sort order. + c2 = client.post(f'/api/supportteams/{teamid}/contacts', + json={'name': 'Second', 'sso': '222', 'sortorder': 2}, + headers=auth_headers) + assert c2.status_code == 201, c2.get_json() + c1 = client.post(f'/api/supportteams/{teamid}/contacts', + json={'name': 'First', 'sso': '111', 'sortorder': 1}, + headers=auth_headers) + assert c1.status_code == 201 + + # Team now nests active contacts in sortorder. + team = client.get(f'/api/supportteams/{teamid}', headers=auth_headers).get_json()['data'] + names = [c['name'] for c in team['contacts']] + assert names == ['First', 'Second'] + + # Update one contact. + contactid = c1.get_json()['data']['contactid'] + upd = client.put(f'/api/supportteams/{teamid}/contacts/{contactid}', + json={'name': 'First Updated'}, headers=auth_headers) + assert upd.status_code == 200 + assert upd.get_json()['data']['name'] == 'First Updated' + + # Delete the other contact. + otherid = c2.get_json()['data']['contactid'] + dele = client.delete(f'/api/supportteams/{teamid}/contacts/{otherid}', + headers=auth_headers) + assert dele.status_code == 200 + team = client.get(f'/api/supportteams/{teamid}', headers=auth_headers).get_json()['data'] + assert [c['name'] for c in team['contacts']] == ['First Updated'] + + +def test_contact_requires_name(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid'] + resp = client.post(f'/api/supportteams/{teamid}/contacts', json={}, + headers=auth_headers) + assert resp.status_code == 400 + + +def test_delete_team_cascades_contacts(client, db, auth_headers): + from shopdb.core.models import SupportTeamContact + teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid'] + client.post(f'/api/supportteams/{teamid}/contacts', + json={'name': 'Someone'}, headers=auth_headers) + client.delete(f'/api/supportteams/{teamid}', headers=auth_headers) + assert SupportTeamContact.query.filter_by(supportteamid=teamid).count() == 0 + + +# --------------------------------------------------------------------------- +# Application carries the team + flattened contacts +# --------------------------------------------------------------------------- + +def test_application_carries_supportteam_payload(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'Controls', + 'https://sn.example/controls').get_json()['data']['supportteamid'] + client.post(f'/api/supportteams/{teamid}/contacts', + json={'name': 'Alice', 'sso': 'a01', 'sortorder': 0}, + headers=auth_headers) + + appresp = client.post('/api/applications', + json={'appname': 'TeamApp', 'supportteamid': teamid}, + headers=auth_headers) + appid = appresp.get_json()['data']['appid'] + + got = client.get(f'/api/applications/{appid}', headers=auth_headers) + data = got.get_json()['data'] + assert data['supportteamid'] == teamid + assert data['supportteamname'] == 'Controls' + assert data['teamurl'] == 'https://sn.example/controls' + assert data['contacts'] == [{'name': 'Alice', 'sso': 'a01'}] + + +# --------------------------------------------------------------------------- +# Import-mode timestamps +# --------------------------------------------------------------------------- + +def test_import_mode_preserves_team_timestamps(client, db, auth_headers): + resp = client.post('/api/supportteams', + json={'teamname': 'Legacy', + 'createddate': LEGACY_CREATED, + 'modifieddate': LEGACY_MODIFIED}, + headers=_import_headers(auth_headers)) + assert resp.status_code == 201, resp.get_json() + + from shopdb.core.models import SupportTeam + team = SupportTeam.query.filter_by(teamname='Legacy').first() + assert team.createddate.year == 2020 and team.createddate.month == 1 + assert team.modifieddate.year == 2021 + + +def test_import_mode_preserves_contact_timestamps(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid'] + resp = client.post(f'/api/supportteams/{teamid}/contacts', + json={'name': 'LegacyOwner', + 'createddate': LEGACY_CREATED, + 'modifieddate': LEGACY_MODIFIED}, + headers=_import_headers(auth_headers)) + assert resp.status_code == 201, resp.get_json() + + from shopdb.core.models import SupportTeamContact + contact = SupportTeamContact.query.filter_by(name='LegacyOwner').first() + assert contact.createddate.year == 2020 + assert contact.modifieddate.year == 2021 + + +# --------------------------------------------------------------------------- +# Authz (belt-and-suspenders; the sweep in test_authz also covers these) +# --------------------------------------------------------------------------- + +def test_member_cannot_create_team(client, db, member_headers): + resp = client.post('/api/supportteams', json={'teamname': 'X'}, + headers=member_headers) + assert resp.status_code == 403 diff --git a/tests/test_plugin_contract.py b/tests/test_plugin_contract.py new file mode 100644 index 00000000..2ea0f1cf --- /dev/null +++ b/tests/test_plugin_contract.py @@ -0,0 +1,309 @@ +"""Plugin contract compliance tests. + +Asserts every bundled plugin satisfies the contract surface declared in +ADR-001 and the BasePlugin ABC. Run on every change to BasePlugin or any +plugin's plugin.py / manifest.json. +""" + +import json +import re +from pathlib import Path + +import pytest +from flask import Blueprint + +from shopdb import __contract_version__ +from shopdb.plugins import plugin_manager +from shopdb.plugins.base import BasePlugin, PluginMeta + + +BUNDLED_PLUGINS = ('computers', 'employees', 'geenforce', 'knowledgebase', 'machines', 'measuringtools', 'network', 'notifications', 'printers', 'slides', 'usb', 'warranty') + + +@pytest.fixture +def plugin_classes(app): + """Load each bundled plugin's class without instantiating in app context.""" + classes = {} + with app.app_context(): + loader = plugin_manager.loader + for name in BUNDLED_PLUGINS: + cls = loader.load_plugin_class(name) + assert cls is not None, f'Plugin class for {name} could not be loaded' + classes[name] = cls + return classes + + +@pytest.fixture +def plugin_instances(app, plugin_classes): + """Instantiate each bundled plugin in app context.""" + return {name: cls() for name, cls in plugin_classes.items()} + + +def test_contract_version_is_set(): + """Framework declares a contract version.""" + assert __contract_version__ + parts = __contract_version__.split('.') + assert len(parts) == 3, f'Expected semver X.Y.Z, got {__contract_version__}' + for part in parts: + assert part.isdigit(), f'Non-numeric semver part in {__contract_version__}' + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_class_subclasses_baseplugin(plugin_classes, name): + """Each plugin class extends BasePlugin.""" + cls = plugin_classes[name] + assert issubclass(cls, BasePlugin), f'{name} does not subclass BasePlugin' + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_manifest_has_required_fields(name): + """Each plugin's manifest.json declares the required fields.""" + manifestpath = Path('plugins') / name / 'manifest.json' + assert manifestpath.exists(), f'{name} is missing manifest.json' + + manifest = json.loads(manifestpath.read_text()) + + assert manifest.get('name') == name + assert manifest.get('version'), f'{name} manifest missing version' + assert manifest.get('description'), f'{name} manifest missing description' + assert manifest.get('core_version'), f'{name} manifest missing core_version' + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_meta_returns_pluginmeta(plugin_instances, name): + """plugin.meta returns a PluginMeta instance with sane fields.""" + plugin = plugin_instances[name] + meta = plugin.meta + assert isinstance(meta, PluginMeta) + assert meta.name == name + assert meta.version + assert meta.api_prefix and meta.api_prefix.startswith('/api/') + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_get_blueprint_returns_blueprint_or_none(plugin_instances, name): + """get_blueprint returns a Flask Blueprint or None.""" + plugin = plugin_instances[name] + bp = plugin.get_blueprint() + assert bp is None or isinstance(bp, Blueprint), ( + f'{name}.get_blueprint returned {type(bp).__name__}' + ) + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_get_models_returns_list(plugin_instances, name): + """get_models returns a list of model classes.""" + plugin = plugin_instances[name] + models = plugin.get_models() + assert isinstance(models, list), f'{name}.get_models did not return a list' + for model in models: + assert hasattr(model, '__tablename__'), ( + f'{name}.get_models returned {model} without __tablename__' + ) + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_get_collector_schema_returns_dict_or_none(plugin_instances, name): + """get_collector_schema returns None or a dict with identityfield + fields.""" + plugin = plugin_instances[name] + schema = plugin.get_collector_schema() + if schema is None: + return + assert isinstance(schema, dict) + assert 'identityfield' in schema, ( + f'{name}.get_collector_schema must declare identityfield' + ) + assert 'fields' in schema, ( + f'{name}.get_collector_schema must declare fields' + ) + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_get_navigation_items_is_iterable(plugin_instances, name): + """get_navigation_items returns an iterable (default empty list).""" + plugin = plugin_instances[name] + items = plugin.get_navigation_items() + assert isinstance(items, list) + + +def test_baseplugin_has_no_searchable_fields_hook(): + """get_searchable_fields was removed: global search is a core concern over + the asset model, and no plugin ever implemented the hook (contract 0.4.0).""" + assert not hasattr(BasePlugin, 'get_searchable_fields') + + +def test_baseplugin_has_dashboard_widgets_hook(): + """The dashboard widgets hook is on the contract surface and consumed.""" + assert hasattr(BasePlugin, 'get_dashboard_widgets') + + +def test_baseplugin_has_reports_hook(): + """The reports hook is on the contract surface (contract 0.6.0).""" + assert hasattr(BasePlugin, 'get_reports') + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_get_reports_is_iterable(plugin_instances, name): + """get_reports returns a list (default empty).""" + plugin = plugin_instances[name] + reports = plugin.get_reports() + assert isinstance(reports, list) + # Each card carries an id/name/category plus exactly one of route/endpoint. + for card in reports: + assert card.get('id') and card.get('name') and card.get('category') + assert ('route' in card) ^ ('endpoint' in card), ( + f'{name} report card must have exactly one of route/endpoint' + ) + + +def test_baseplugin_has_permissions_hook(): + """The permissions hook is on the contract surface (contract 0.10.0).""" + assert hasattr(BasePlugin, 'get_permissions') + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_get_permissions_shape(plugin_instances, name): + """get_permissions returns a list of (name, description, category) entries.""" + plugin = plugin_instances[name] + perms = plugin.get_permissions() + assert isinstance(perms, list) + for entry in perms: + if isinstance(entry, dict): + pname, category = entry['name'], entry.get('category') + else: + assert len(entry) == 3, f'{name}: entry must be a 3-tuple' + pname, _desc, category = entry + assert isinstance(pname, str) and '.' in pname, ( + f'{name}: permission name {pname!r} must be dotted') + assert category, f'{name}: permission {pname} needs a category' + + +def test_baseplugin_has_frontend_contribution_hooks(): + """The four ADR-010 frontend-contribution hooks are on the contract (0.7.0).""" + for hook in ('get_settings_cards', 'get_asset_panels', + 'get_map_overlays', 'get_asset_presentation'): + assert hasattr(BasePlugin, hook), f'{hook} missing from BasePlugin' + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_frontend_hooks_return_lists(plugin_instances, name): + """The four ADR-010 hooks default to a list on every bundled plugin.""" + plugin = plugin_instances[name] + assert isinstance(plugin.get_settings_cards(), list) + assert isinstance(plugin.get_asset_panels(), list) + assert isinstance(plugin.get_map_overlays(), list) + assert isinstance(plugin.get_asset_presentation(), list) + + +def test_get_services_hook_has_consumer(app): + """get_services is consumed by plugin_manager.get_service (no dead hook).""" + with app.app_context(): + pm = app.extensions['plugin_manager'] + assert hasattr(pm, 'get_service') + # Unknown service name resolves to None, not an error. + assert pm.get_service('definitely-not-a-real-service') is None + + +def test_baseplugin_does_not_have_event_handlers_hook(): + """The removed get_event_handlers hook must not be on BasePlugin.""" + assert not hasattr(BasePlugin, 'get_event_handlers'), ( + 'get_event_handlers was removed for v1. See ADR-001 contract surface.' + ) + + +def test_baseplugin_has_collector_schema_hook(): + """The collector schema hook is on the contract surface.""" + assert hasattr(BasePlugin, 'get_collector_schema') + + +def test_baseplugin_has_apply_collector_payload_hook(): + """The collector upsert hook is on the contract surface (ADR-006).""" + assert hasattr(BasePlugin, 'apply_collector_payload') + + +def test_schema_declaring_plugins_implement_apply(plugin_instances): + """Any plugin returning a collector schema must implement the upsert hook.""" + for name, plugin in plugin_instances.items(): + if plugin.get_collector_schema() is not None: + overridden = type(plugin).apply_collector_payload \ + is not BasePlugin.apply_collector_payload + assert overridden, ( + f'Plugin {name} declares a collector schema but does not ' + f'override apply_collector_payload' + ) + + +# Imports a plugin may make from the core. shopdb.api is the contract surface; +# shopdb.plugins.base is the plugin ABC. Anything else (shopdb.core.*, +# shopdb.extensions, shopdb.utils.*) is a contract violation per ADR-001. +ALLOWED_CORE_IMPORTS = ('shopdb.api', 'shopdb.plugins.base') + +_PLUGIN_IMPORT_RE = re.compile( + r'^\s*(?:from (shopdb[\w.]*) import|import (shopdb[\w.]*))', re.MULTILINE +) + + +def _plugin_source_files(): + root = Path(__file__).resolve().parent.parent / 'plugins' + # Skip migrations/ - Alembic env.py is framework glue that legitimately + # calls the shared runner in shopdb.plugins.alembic_template (see ADR-008), + # not plugin domain code. Mirrors the style check excluding versions/. + # Resolve each plugin dir before rglob: rglob does not descend symlinked + # directories, and external plugins arrive as symlinks per ADR-003. + files = [p for p in root.glob('*.py')] + for plugindir in sorted(root.iterdir()): + if not plugindir.is_dir(): + continue + for path in plugindir.resolve().rglob('*.py'): + if '__pycache__' in path.parts or 'migrations' in path.parts: + continue + files.append(path) + return files + + +def test_plugins_only_import_contract_surface(): + """Plugins must import core code only via shopdb.api / shopdb.plugins.base.""" + violations = [] + for path in _plugin_source_files(): + text = path.read_text() + for match in _PLUGIN_IMPORT_RE.finditer(text): + module = match.group(1) or match.group(2) + if not module.startswith('shopdb'): + continue + if any(module == a or module.startswith(a + '.') + for a in ALLOWED_CORE_IMPORTS): + continue + line = text[:match.start()].count('\n') + 1 + violations.append(f'{path.name}:{line} imports {module}') + assert not violations, ( + 'Plugins must import core only via shopdb.api or shopdb.plugins.base. ' + 'Violations:\n' + '\n'.join(violations) + ) + + +def test_import_scan_covers_symlinked_plugins(tmp_path): + """External plugins symlinked into plugins/ ARE scanned for violations. + + Path.rglob does not descend symlinked directories, so a naive scan would + silently skip external plugins installed per ADR-003 (clone or symlink + into plugins//). The scanner resolves each plugin dir first; this + test plants a symlinked plugin with a violating import and asserts the + scan both sees the file and flags the violation. + """ + root = Path(__file__).resolve().parent.parent / 'plugins' + external = tmp_path / 'symlinkdemo' + external.mkdir() + # violating import: internal core path, not the contract surface + (external / 'plugin.py').write_text('from shopdb.extensions import db\n') + + link = root / 'zzsymlinkdemo' + link.symlink_to(external, target_is_directory=True) + try: + scanned = {str(p) for p in _plugin_source_files()} + assert any('symlinkdemo' in s for s in scanned), ( + 'symlinked plugin sources were not scanned' + ) + with pytest.raises(AssertionError, match='shopdb.extensions'): + test_plugins_only_import_contract_surface() + finally: + link.unlink() diff --git a/tests/test_plugin_lifecycle.py b/tests/test_plugin_lifecycle.py new file mode 100644 index 00000000..1d0d6290 --- /dev/null +++ b/tests/test_plugin_lifecycle.py @@ -0,0 +1,165 @@ +"""Plugin lifecycle + registry tests. + +Covers the framework's own core feature - the plugin manager's enable/disable +guards and permission seeding, plus the registry's persistence and the +equipment->machines rename migration. These paths had no direct coverage; a +dropped dependency check or a broken rename migration would let an operator +disable a required plugin (breaking boot) with no failing test. +""" + +import json + +import pytest + +from shopdb.plugins import PluginManager +from shopdb.plugins.loader import PluginLoader +from shopdb.plugins.registry import PluginRegistry + + +# --- Registry persistence + rename migration -------------------------------- + +def test_registry_disable_survives_reload(tmp_path): + """A disabled plugin stays disabled when the registry is re-read.""" + path = tmp_path / 'plugins.json' + reg = PluginRegistry(path) + reg.register('demo', '1.0.0', enabled=True) + reg.disable('demo') + + reloaded = PluginRegistry(path) + assert reloaded.is_installed('demo') + assert not reloaded.is_enabled('demo') + + +def test_registry_recovers_from_corrupt_file(tmp_path): + """An unparseable plugins.json loads as empty instead of crashing.""" + path = tmp_path / 'plugins.json' + path.write_text('{ this is not valid json') + reg = PluginRegistry(path) + assert reg.get_all() == {} + + +def test_registry_migrates_equipment_to_machines(tmp_path): + """A legacy 'equipment' entry is carried over to 'machines' on load (the + real plugins/machines dir exists), preserving enabled state, once.""" + path = tmp_path / 'plugins.json' + seed = PluginRegistry(path) + seed.register('equipment', '1.0.0', enabled=True) + assert seed.is_installed('equipment') + + migrated = PluginRegistry(path) # _load runs the rename migration + assert migrated.is_installed('machines') + assert migrated.is_enabled('machines') + assert not migrated.is_installed('equipment') + + # Persisted: a third read sees machines, and never re-migrates. + again = PluginRegistry(path) + assert again.is_installed('machines') + + +# --- Manager enable/disable dependency guards + permission seeding ----------- + +def _write_plugin(plugins_dir, name, deps=None, permissions=None): + """Write a minimal valid synthetic plugin (manifest.json + plugin.py).""" + pdir = plugins_dir / name + pdir.mkdir() + (pdir / 'manifest.json').write_text(json.dumps({ + 'name': name, + 'version': '1.0.0', + 'description': f'synthetic {name}', + 'dependencies': deps or [], + 'core_version': '>=0.1.0,<1.0.0', + 'api_prefix': f'/api/{name}', + })) + (pdir / '__init__.py').write_text('') + (pdir / 'plugin.py').write_text( + 'from shopdb.plugins.base import BasePlugin, PluginMeta\n\n' + 'class ThePlugin(BasePlugin):\n' + ' @property\n' + ' def meta(self):\n' + f' return PluginMeta(name={name!r}, version="1.0.0",\n' + f' description="synthetic", dependencies={deps or []!r})\n' + ' def get_blueprint(self):\n' + ' return None\n' + ' def get_models(self):\n' + ' return []\n' + ' def get_permissions(self):\n' + f' return {permissions or []!r}\n' + ) + + +@pytest.fixture +def manager(app, db, tmp_path): + """A PluginManager wired to a temp plugins dir + registry, with synthetic + plugins 'alpha' (owns a permission) and 'beta' (depends on alpha).""" + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + _write_plugin(plugins_dir, 'alpha', + permissions=[('alpha.view', 'View alpha', 'alpha')]) + _write_plugin(plugins_dir, 'beta', deps=['alpha']) + + registry = PluginRegistry(tmp_path / 'plugins.json') + registry.register('alpha', '1.0.0', enabled=False) + registry.register('beta', '1.0.0', enabled=False) + + pluginmanager = PluginManager() + pluginmanager._app = app + pluginmanager._db = db + pluginmanager.registry = registry + pluginmanager.loader = PluginLoader(plugins_dir, registry) + pluginmanager.migration_manager = None + return pluginmanager + + +def test_enable_blocked_until_dependency_enabled(manager): + """beta depends on alpha: enabling beta first is refused.""" + assert manager.enable_plugin('beta') is False + assert not manager.registry.is_enabled('beta') + + assert manager.enable_plugin('alpha') is True + assert manager.enable_plugin('beta') is True + + +def test_disable_blocked_while_dependent_enabled(manager): + """alpha cannot be disabled while beta (which depends on it) is enabled.""" + manager.enable_plugin('alpha') + manager.enable_plugin('beta') + + assert manager.disable_plugin('alpha') is False + assert manager.registry.is_enabled('alpha') + + # Disable the dependent first, then alpha frees up. + assert manager.disable_plugin('beta') is True + assert manager.disable_plugin('alpha') is True + + +def test_enable_seeds_plugin_permissions(manager, app, db): + """Enabling a plugin idempotently seeds its declared RBAC permissions.""" + from shopdb.core.models import Permission + + with app.app_context(): + assert Permission.query.filter_by(name='alpha.view').first() is None + + assert manager.enable_plugin('alpha') is True + with app.app_context(): + assert Permission.query.filter_by(name='alpha.view').first() is not None + + # Re-enable is a no-op (already enabled) and never duplicates the row. + manager.enable_plugin('alpha') + with app.app_context(): + assert Permission.query.filter_by(name='alpha.view').count() == 1 + + +# --- Seed idempotency ------------------------------------------------------- + +def test_seed_settings_is_idempotent(app, db): + """A second `flask seed settings` pass creates zero net-new rows.""" + from shopdb.core.models import Setting + + runner = app.test_cli_runner() + assert runner.invoke(args=['seed', 'settings']).exit_code in (0, None) + with app.app_context(): + first = Setting.query.count() + assert runner.invoke(args=['seed', 'settings']).exit_code in (0, None) + with app.app_context(): + second = Setting.query.count() + assert second == first diff --git a/tests/test_plugin_loader.py b/tests/test_plugin_loader.py new file mode 100644 index 00000000..e31d4aaa --- /dev/null +++ b/tests/test_plugin_loader.py @@ -0,0 +1,261 @@ +"""Tests for the plugin loader behavior. + +Pins the manifest-first design and the strict-vs-isolate failure policy +described in the enforcing-plugin-contract skill. +""" + +import json +from pathlib import Path + +import pytest + +from shopdb.plugins.loader import PluginLoader +from shopdb.plugins.registry import PluginRegistry +from shopdb.exceptions import ( + PluginNotFoundError, + PluginContractError, + PluginVersionError, + PluginDependencyError, + PluginError, +) + + +@pytest.fixture +def temp_plugins_dir(tmp_path): + """An empty temporary plugins directory.""" + plugins = tmp_path / 'plugins' + plugins.mkdir() + return plugins + + +@pytest.fixture +def temp_registry(tmp_path): + """An empty temporary registry.""" + return PluginRegistry(tmp_path / 'plugins.json') + + +@pytest.fixture +def loader(temp_plugins_dir, temp_registry): + """A loader pointed at the temporary plugins dir.""" + return PluginLoader(temp_plugins_dir, temp_registry) + + +def write_plugin(plugins_dir, name, manifest=None, plugin_py=None): + """Helper: write a plugin directory with manifest.json and plugin.py.""" + plugin_dir = plugins_dir / name + plugin_dir.mkdir() + + if manifest is not None: + (plugin_dir / 'manifest.json').write_text(json.dumps(manifest)) + + if plugin_py is not None: + (plugin_dir / 'plugin.py').write_text(plugin_py) + + return plugin_dir + + +VALID_PLUGIN_PY = ''' +from shopdb.plugins.base import BasePlugin, PluginMeta + + +class FakePlugin(BasePlugin): + @property + def meta(self): + return PluginMeta( + name='fake', + version='1.0.0', + description='Fake test plugin', + ) + + def get_blueprint(self): + return None + + def get_models(self): + return [] +''' + + +def test_load_manifest_raises_on_missing_manifest(loader, temp_plugins_dir): + """A plugin without manifest.json raises PluginNotFoundError.""" + plugin_dir = temp_plugins_dir / 'noplugin' + plugin_dir.mkdir() + (plugin_dir / 'plugin.py').write_text('# empty') + + with pytest.raises(PluginNotFoundError, match='manifest.json'): + loader.load_manifest('noplugin') + + +def test_load_manifest_raises_on_invalid_json(loader, temp_plugins_dir): + """An unparseable manifest.json raises PluginContractError.""" + plugin_dir = temp_plugins_dir / 'bad' + plugin_dir.mkdir() + (plugin_dir / 'manifest.json').write_text('{not json}') + + with pytest.raises(PluginContractError, match='invalid manifest.json'): + loader.load_manifest('bad') + + +def test_load_manifest_raises_on_missing_required_fields(loader, temp_plugins_dir): + """Missing name/version/description raises PluginContractError.""" + write_plugin(temp_plugins_dir, 'incomplete', manifest={'name': 'incomplete'}) + + with pytest.raises(PluginContractError, match='missing required field'): + loader.load_manifest('incomplete') + + +def test_load_manifest_raises_on_name_mismatch(loader, temp_plugins_dir): + """manifest.name must match the directory name.""" + write_plugin( + temp_plugins_dir, + 'mydir', + manifest={'name': 'different', 'version': '1.0.0', 'description': 'x'}, + ) + + with pytest.raises(PluginContractError, match='does not match manifest name'): + loader.load_manifest('mydir') + + +def test_check_contract_version_passes_when_in_range(loader, temp_plugins_dir): + """A core_version range that covers the framework version passes.""" + write_plugin( + temp_plugins_dir, + 'compat', + manifest={ + 'name': 'compat', + 'version': '1.0.0', + 'description': 'x', + 'core_version': '>=0.1.0,<1.0.0', + }, + ) + loader.check_contract_version('compat', '0.1.0') + + +def test_check_contract_version_raises_when_out_of_range(loader, temp_plugins_dir): + """A core_version range that excludes the framework version raises.""" + write_plugin( + temp_plugins_dir, + 'incompat', + manifest={ + 'name': 'incompat', + 'version': '1.0.0', + 'description': 'x', + 'core_version': '>=2.0.0', + }, + ) + + with pytest.raises(PluginVersionError, match='requires core_version'): + loader.check_contract_version('incompat', '0.1.0') + + +def test_check_contract_version_skipped_when_unspecified(loader, temp_plugins_dir): + """A manifest without core_version does not raise.""" + write_plugin( + temp_plugins_dir, + 'unspec', + manifest={ + 'name': 'unspec', + 'version': '1.0.0', + 'description': 'x', + }, + ) + loader.check_contract_version('unspec', '0.1.0') + + +def test_load_plugin_class_raises_on_missing_plugin_py(loader, temp_plugins_dir): + """A directory with no plugin.py raises PluginNotFoundError.""" + plugin_dir = temp_plugins_dir / 'nofile' + plugin_dir.mkdir() + + with pytest.raises(PluginNotFoundError, match='plugin.py not found'): + loader.load_plugin_class('nofile') + + +def test_load_plugin_class_raises_when_no_subclass_found(loader, temp_plugins_dir): + """A plugin.py without a BasePlugin subclass raises PluginContractError.""" + write_plugin( + temp_plugins_dir, + 'empty', + manifest={'name': 'empty', 'version': '1.0.0', 'description': 'x'}, + plugin_py='# no plugin defined', + ) + + with pytest.raises(PluginContractError, match='no BasePlugin subclass'): + loader.load_plugin_class('empty') + + +def test_load_plugin_class_raises_on_import_error(loader, temp_plugins_dir): + """A plugin.py with a syntax/import error raises PluginContractError.""" + write_plugin( + temp_plugins_dir, + 'broken', + manifest={'name': 'broken', 'version': '1.0.0', 'description': 'x'}, + plugin_py='import nonexistent_module_xyz', + ) + + with pytest.raises(PluginContractError, match='import failed'): + loader.load_plugin_class('broken') + + +def test_sort_by_dependencies_uses_manifest_not_instantiation(loader, temp_plugins_dir): + """Topological sort reads manifest.json directly, never instantiates.""" + write_plugin( + temp_plugins_dir, + 'a', + manifest={ + 'name': 'a', + 'version': '1.0.0', + 'description': 'x', + 'dependencies': ['b'], + }, + ) + write_plugin( + temp_plugins_dir, + 'b', + manifest={'name': 'b', 'version': '1.0.0', 'description': 'x'}, + ) + + sorted_names = loader._sort_by_dependencies(['a', 'b']) + assert sorted_names.index('b') < sorted_names.index('a') + + +def test_load_plugin_strict_mode_reraises(loader, app, temp_plugins_dir): + """In TESTING/DEBUG mode, load_plugin re-raises plugin errors.""" + write_plugin( + temp_plugins_dir, + 'incompat', + manifest={ + 'name': 'incompat', + 'version': '1.0.0', + 'description': 'x', + 'core_version': '>=99.0.0', + }, + plugin_py=VALID_PLUGIN_PY, + ) + + assert app.config.get('TESTING') is True + with pytest.raises(PluginVersionError): + loader.load_plugin('incompat', app, None) + + +def test_load_plugin_production_mode_isolates(loader, temp_plugins_dir): + """In production-like config, load_plugin returns None on failure.""" + from flask import Flask + + fake_app = Flask(__name__) + fake_app.config['TESTING'] = False + fake_app.config['DEBUG'] = False + + write_plugin( + temp_plugins_dir, + 'incompat', + manifest={ + 'name': 'incompat', + 'version': '1.0.0', + 'description': 'x', + 'core_version': '>=99.0.0', + }, + plugin_py=VALID_PLUGIN_PY, + ) + + result = loader.load_plugin('incompat', fake_app, None) + assert result is None diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py new file mode 100644 index 00000000..16e02690 --- /dev/null +++ b/tests/test_plugin_migrations.py @@ -0,0 +1,242 @@ +"""Per-plugin Alembic migration-chain guard tests. + +Ownership cutover (ADR-008): the core Alembic chain created every table that +exists through its head (`7d16_directoryemployees`), including the plugin +tables. From that point on, each bundled plugin that owns tables carries its +own chain under `plugins//migrations/`. The `0001` migration in each +chain is a stamp-only no-op anchor: the core chain already built the tables, so +there is nothing to create; the anchor just gives the plugin chain a base that +`flask plugin upgrade-all` stamps into `alembic_version_`. + +These tests pin that contract: + * PLUGIN_TABLE_OWNERS stays in sync with what the models declare. + * Every plugin that owns tables has a valid single-head chain. + * The anchor migrations are genuine no-ops. + * `flask plugin upgrade-all` runs clean on a fresh DB and is idempotent. +""" +import ast +from pathlib import Path + +import pytest + +from shopdb.plugins.alembic_template import ( + PLUGIN_TABLE_OWNERS, + _get_plugin_metadata, +) + +PLUGINS_DIR = Path(__file__).resolve().parent.parent / 'plugins' + +# Plugins that own tables carry a migration chain; blueprint-only plugins do +# not. Today every bundled plugin owns tables, so this is the full set. +TABLE_OWNING_PLUGINS = tuple(sorted(PLUGIN_TABLE_OWNERS)) + +# The ADR-008 cutover froze this exact set of ten plugins whose tables the core +# chain had already created. Their 0001 revision is a stamp-only no-op anchor. +# Plugins built AFTER the cutover (e.g. measuringtools) are NOT in this list: +# their 0001 is a real baseline that genuinely creates their tables, so the +# no-op assertion must not apply to them. This is a frozen list on purpose - a +# newly discovered plugin does not silently get treated as a cutover no-op. +CUTOVER_PLUGINS = ( + 'computers', 'employees', 'knowledgebase', 'machines', 'network', + 'notifications', 'printers', 'slides', 'usb', 'warranty', +) + +# Expected head revision id per table-owning plugin, so the upgrade-all test can +# check both the cutover anchors and post-cutover baselines. Cutover plugins +# stamp '0001anchor'; measuringtools stamps its real baseline id. +EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS} +EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline' +EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0001baseline' +# machines (renamed from equipment) keeps its original anchor id and adds the +# rename revision on top, so its head is not the f-string default. +EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename' +# employees adds the photofilename column on top of its cutover anchor. +EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo' +# usb drops the dead usbcheckouts.machineid column on top of its anchor. +EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid' +# notifications indexes businessunitid on top of its anchor. +EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx' + +# Plugins built after the cutover: their 0001 baseline really creates tables the +# core chain never owned. +POST_CUTOVER_PLUGINS = tuple(p for p in PLUGIN_TABLE_OWNERS if p not in CUTOVER_PLUGINS) + + +def _declared_tablenames(plugin: str) -> set: + """Scan a plugin's models package for every __tablename__ literal. + + Static parse (no import) so the test can compare what the code declares + against PLUGIN_TABLE_OWNERS without side effects. + """ + names = set() + models_dir = PLUGINS_DIR / plugin / 'models' + if not models_dir.exists(): + return names + for source in models_dir.glob('*.py'): + tree = ast.parse(source.read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + targets = [t.id for t in node.targets if isinstance(t, ast.Name)] + if '__tablename__' in targets and isinstance(node.value, ast.Constant): + names.add(node.value.value) + return names + + +def test_table_owners_match_declared_models(): + """Every plugin that declares a __tablename__ is registered in + PLUGIN_TABLE_OWNERS, and vice versa. Catches a new plugin table that + forgot to update the ownership map.""" + declared = {p.name for p in PLUGINS_DIR.iterdir() + if p.is_dir() and _declared_tablenames(p.name)} + assert declared == set(PLUGIN_TABLE_OWNERS), ( + f"PLUGIN_TABLE_OWNERS keys {set(PLUGIN_TABLE_OWNERS)} do not match " + f"plugins declaring tables {declared}" + ) + + +@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS) +def test_bundled_plugin_has_table_owner_entry(plugin): + """Every table-owning plugin appears in PLUGIN_TABLE_OWNERS with at least + one table, documenting which tables it contributes to the schema.""" + assert plugin in PLUGIN_TABLE_OWNERS + assert len(PLUGIN_TABLE_OWNERS[plugin]) > 0 + + +@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS) +def test_owned_tables_match_declared_models(plugin): + """The tables named in PLUGIN_TABLE_OWNERS are exactly the ones the + plugin's models declare. Catches drift in either direction.""" + assert set(PLUGIN_TABLE_OWNERS[plugin]) == _declared_tablenames(plugin) + + +@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS) +def test_plugin_metadata_has_all_owned_tables(plugin, app): + """The MetaData filtered to a plugin's owned tables actually contains + every table named in PLUGIN_TABLE_OWNERS.""" + with app.app_context(): + md = _get_plugin_metadata(plugin) + owned = set(PLUGIN_TABLE_OWNERS[plugin]) + missing = owned - set(md.tables.keys()) + assert not missing, f"Plugin {plugin}: owned tables not in metadata: {missing}" + + +@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS) +def test_plugin_has_migration_chain(plugin): + """Every table-owning plugin has a migrations dir with env.py and exactly + one anchor revision whose down_revision is None (a valid single-root + chain).""" + mig = PLUGINS_DIR / plugin / 'migrations' + assert (mig / 'env.py').exists(), f"{plugin}: missing migrations/env.py" + versions = sorted((mig / 'versions').glob('*.py')) + assert versions, f"{plugin}: no version scripts" + + roots = [] + heads = set() + down_revisions = set() + revisions = set() + for script in versions: + tree = ast.parse(script.read_text()) + rev = down = None + found_down = False + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + names = [t.id for t in node.targets if isinstance(t, ast.Name)] + if 'revision' in names and isinstance(node.value, ast.Constant): + rev = node.value.value + if 'down_revision' in names: + found_down = True + if isinstance(node.value, ast.Constant): + down = node.value.value + assert rev, f"{plugin}: {script.name} has no revision id" + assert found_down, f"{plugin}: {script.name} has no down_revision" + revisions.add(rev) + if down is None: + roots.append(rev) + else: + down_revisions.add(down) + heads = revisions - down_revisions + assert len(roots) == 1, f"{plugin}: expected 1 root revision, got {roots}" + assert len(heads) == 1, f"{plugin}: chain must have a single head, got {heads}" + + +@pytest.mark.parametrize('plugin', CUTOVER_PLUGINS) +def test_anchor_migration_is_noop(plugin): + """The 0001 anchor's upgrade() and downgrade() are pure no-ops: no DDL + operations, just `pass`. The core chain owns the tables at cutover. + + Scoped to CUTOVER_PLUGINS only. A plugin built after the cutover ships a + real baseline (measuringtools), which is deliberately NOT a no-op.""" + anchor = PLUGINS_DIR / plugin / 'migrations' / 'versions' / f'0001_{plugin}_anchor.py' + assert anchor.exists(), f"{plugin}: missing 0001 anchor migration" + tree = ast.parse(anchor.read_text()) + funcs = {n.name: n for n in ast.walk(tree) + if isinstance(n, ast.FunctionDef) and n.name in ('upgrade', 'downgrade')} + assert set(funcs) == {'upgrade', 'downgrade'}, f"{plugin}: anchor missing up/downgrade" + for name, fn in funcs.items(): + # Body may only be a docstring/comment plus a bare `pass`. No calls. + calls = [n for n in ast.walk(fn) if isinstance(n, ast.Call)] + assert not calls, f"{plugin}: anchor {name}() is not a no-op (found calls)" + + +def test_upgrade_all_on_fresh_db_is_clean_and_idempotent(tmp_path, monkeypatch): + """`flask plugin upgrade-all` on a fresh SQLite DB (after the core schema + is created) stamps every plugin anchor without error, and a second run is + a no-op. Mirrors the deploy sequence: `flask db upgrade` then + `flask plugin upgrade-all`.""" + from sqlalchemy import inspect, text + from shopdb.config import TestingConfig + from shopdb import create_app + from shopdb.extensions import db + from shopdb.plugins import plugin_manager + + db_file = tmp_path / 'fresh.db' + url = f'sqlite:///{db_file}' + + # Point the whole app (db engine + migration manager) at one file DB so the + # anchor stamps land where the app can read them back. + monkeypatch.setattr(TestingConfig, 'SQLALCHEMY_DATABASE_URI', url) + + # create_app repoints the process-wide plugin_manager singleton; snapshot + # its wiring and restore it so later tests see the session app unchanged. + saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry, + plugin_manager.loader, plugin_manager.migration_manager, + plugin_manager._registered_prefixes) + try: + app = create_app('testing') + with app.app_context(): + db.create_all() # stand in for the core `flask db upgrade` + + # db.create_all() over-creates: it builds EVERY table registered on + # the metadata, including post-cutover plugin tables the core chain + # would never own. Drop those so each post-cutover baseline creates + # its own tables exactly as it does after a real core upgrade (where + # the tables are simply absent). The cutover anchors are no-ops, so + # their create_all-built tables stay put. + insp0 = inspect(db.engine) + for plugin in POST_CUTOVER_PLUGINS: + # Drop by owned name (SQLite tolerates any order with no rows); + # avoids resolving cross-metadata FKs via sorted_tables. + for tablename in PLUGIN_TABLE_OWNERS[plugin]: + if insp0.has_table(tablename): + db.session.execute(text(f'DROP TABLE {tablename}')) + db.session.commit() + + first = app.extensions['plugin_manager'].upgrade_all_plugins() + second = app.extensions['plugin_manager'].upgrade_all_plugins() + + assert set(first) == set(PLUGIN_TABLE_OWNERS) + assert all(status == 'ok' for status in first.values()), first + assert all(status == 'ok' for status in second.values()), second + + insp = inspect(db.engine) + for plugin in PLUGIN_TABLE_OWNERS: + version_table = f'alembic_version_{plugin}' + assert insp.has_table(version_table), f"missing {version_table}" + row = db.session.execute( + text(f'SELECT version_num FROM {version_table}') + ).fetchone() + assert row and row[0] == EXPECTED_HEAD_REVISION[plugin] + finally: + (plugin_manager._app, plugin_manager._db, plugin_manager.registry, + plugin_manager.loader, plugin_manager.migration_manager, + plugin_manager._registered_prefixes) = saved diff --git a/tests/test_plugin_scaffold.py b/tests/test_plugin_scaffold.py new file mode 100644 index 00000000..f7e63ede --- /dev/null +++ b/tests/test_plugin_scaffold.py @@ -0,0 +1,189 @@ +"""Canary tests for the plugin scaffolder. + +The scaffold output must satisfy the framework contract the moment it +is generated. If a contract test would fail on a freshly scaffolded +plugin, the scaffold templates are broken, not the plugin author's +mistake. +""" + +import json +import sys +from pathlib import Path + +import pytest +from flask import Blueprint + +from shopdb.plugins.scaffolder import ( + scaffold_plugin, + validate_name, + pascal_case, + ScaffoldError, +) + + +@pytest.fixture(scope='module') +def scaffolded(tmp_path_factory): + """Scaffold a plugin named 'widgets' under a temporary plugins dir. + + Module-scoped so the generated plugin module imports exactly once + per test module. SQLAlchemy registers the model once, no metadata + collision. Returns a tuple (target_dir, plugins_dir) where + plugins_dir is suitable as PluginLoader's plugins_dir argument. + + The scaffold output is loaded via PluginLoader's spec-based import + so the canary does not collide with the real `plugins/` package + that already exists at /plugins/. + """ + tmp_path = tmp_path_factory.mktemp('scaffold') + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + + target = scaffold_plugin( + name='widgets', + description='Test widgets plugin', + plugins_dir=plugins_dir, + ) + + import plugins as real_plugins + real_plugins.__path__.insert(0, str(plugins_dir)) + + try: + yield target, plugins_dir + finally: + try: + real_plugins.__path__.remove(str(plugins_dir)) + except ValueError: + pass + for mod_name in list(sys.modules): + if mod_name.startswith('plugins.widgets'): + del sys.modules[mod_name] + from shopdb.extensions import db as _db + if 'widgets' in _db.metadata.tables: + _db.metadata.remove(_db.metadata.tables['widgets']) + + +def _load_widgets_plugin_class(plugins_dir): + """Load the scaffolded widgets plugin class via PluginLoader.""" + from shopdb.plugins.loader import PluginLoader + from shopdb.plugins.registry import PluginRegistry + + registry = PluginRegistry(plugins_dir.parent / 'plugins.json') + loader = PluginLoader(plugins_dir, registry) + return loader.load_plugin_class('widgets') + + +def test_validate_name_accepts_simple_lowercase(): + validate_name('cameras') + validate_name('barcode') + validate_name('vlan2') + + +def test_validate_name_rejects_underscore(): + with pytest.raises(ScaffoldError, match='lowercase letters and digits'): + validate_name('my_plugin') + + +def test_validate_name_rejects_hyphen(): + with pytest.raises(ScaffoldError, match='lowercase letters and digits'): + validate_name('my-plugin') + + +def test_validate_name_rejects_uppercase(): + with pytest.raises(ScaffoldError, match='lowercase letters and digits'): + validate_name('MyPlugin') + + +def test_validate_name_rejects_leading_digit(): + with pytest.raises(ScaffoldError, match='lowercase letters and digits'): + validate_name('1plugin') + + +def test_validate_name_rejects_reserved(): + with pytest.raises(ScaffoldError, match='reserved'): + validate_name('plugins') + + +def test_pascal_case(): + assert pascal_case('cameras') == 'Cameras' + assert pascal_case('vlan') == 'Vlan' + + +def test_scaffold_creates_expected_files(scaffolded): + """Scaffold output has every file the contract expects.""" + target, _ = scaffolded + expected = [ + 'manifest.json', + 'plugin.py', + '__init__.py', + 'models/__init__.py', + 'models/widgets.py', + 'api/__init__.py', + 'api/routes.py', + 'schemas/__init__.py', + 'tests/__init__.py', + 'tests/test_plugin.py', + 'README.md', + ] + for relpath in expected: + assert (target / relpath).exists(), f'Missing scaffold output: {relpath}' + + +def test_scaffold_manifest_has_required_fields(scaffolded): + """manifest.json contains the fields the loader requires.""" + target, _ = scaffolded + manifest = json.loads((target / 'manifest.json').read_text()) + assert manifest['name'] == 'widgets' + assert manifest['version'] + assert manifest['description'] == 'Test widgets plugin' + assert manifest['core_version'] + assert manifest['api_prefix'] == '/api/widgets' + + +def test_scaffold_plugin_class_is_importable(scaffolded): + """The generated plugin.py loads via PluginLoader and subclasses BasePlugin.""" + from shopdb.plugins.base import BasePlugin + + _, plugins_dir = scaffolded + plugin_class = _load_widgets_plugin_class(plugins_dir) + + assert issubclass(plugin_class, BasePlugin) + instance = plugin_class() + assert instance.meta.name == 'widgets' + + +def test_scaffold_get_blueprint_returns_blueprint(scaffolded): + """The generated plugin's get_blueprint returns a Flask Blueprint.""" + _, plugins_dir = scaffolded + plugin_class = _load_widgets_plugin_class(plugins_dir) + bp = plugin_class().get_blueprint() + assert isinstance(bp, Blueprint) + + +def test_scaffold_get_models_returns_model_class(scaffolded): + """The generated plugin's get_models returns at least one model with a tablename.""" + _, plugins_dir = scaffolded + plugin_class = _load_widgets_plugin_class(plugins_dir) + models = plugin_class().get_models() + assert len(models) == 1 + assert models[0].__tablename__ == 'widgets' + + +def test_scaffold_refuses_to_overwrite_by_default(tmp_path): + """A second scaffold of the same name without overwrite raises.""" + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + scaffold_plugin('widgets', 'first', plugins_dir) + + with pytest.raises(ScaffoldError, match='already exists'): + scaffold_plugin('widgets', 'second', plugins_dir) + + +def test_scaffold_overwrites_when_explicitly_requested(tmp_path): + """overwrite=True allows a re-scaffold.""" + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + scaffold_plugin('widgets', 'first', plugins_dir) + scaffold_plugin('widgets', 'second', plugins_dir, overwrite=True) + + manifest = json.loads((plugins_dir / 'widgets' / 'manifest.json').read_text()) + assert manifest['description'] == 'second' diff --git a/tests/test_plugin_scaffold_frontend.py b/tests/test_plugin_scaffold_frontend.py new file mode 100644 index 00000000..ec6e8dbf --- /dev/null +++ b/tests/test_plugin_scaffold_frontend.py @@ -0,0 +1,252 @@ +"""Canary tests for the plugin scaffolder's frontend generation. + +`flask plugin new ` generates the backend skeleton and, when a frontend +tree is present, a matching Vue frontend starting point: list, detail, and form +views, a router route file, and a paste-in api-client snippet. These tests +guard the file names, the substitutions, the ADR-009 plugin gating on the +routes, the use of global CSS classes over per-page input styling, graceful +skipping when there is no frontend tree, and the no-clobber behaviour. + +All generation runs against tmp_path. The real frontend/src tree is never +touched. +""" + +from pathlib import Path + +import pytest + +from shopdb.plugins.scaffolder import scaffold_plugin, ScaffoldError + + +def _make_dirs(tmp_path: Path): + """Create a tmp plugins dir and a tmp frontend/src tree. + + Returns (plugins_dir, frontend_dir). + """ + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + frontend_dir = tmp_path / 'frontend' / 'src' + frontend_dir.mkdir(parents=True) + return plugins_dir, frontend_dir + + +def test_frontend_files_generated_with_correct_names(tmp_path): + """Views, route file, and api snippet appear with the expected names.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + + target = scaffold_plugin( + name='widgets', + description='Test widgets plugin', + plugins_dir=plugins_dir, + frontend_dir=frontend_dir, + ) + + assert (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').exists() + assert (frontend_dir / 'views' / 'widgets' / 'WidgetsDetail.vue').exists() + assert (frontend_dir / 'views' / 'widgets' / 'WidgetsForm.vue').exists() + assert (frontend_dir / 'router' / 'routes' / 'widgets.js').exists() + # paste-in snippet lands in the plugin dir, not the frontend tree + assert (target / 'frontend-api-snippet.js').exists() + + +def test_frontend_substitutions_applied(tmp_path): + """Name and Name placeholders are substituted, none left raw.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + + route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text() + assert "import('../../views/widgets/WidgetsList.vue')" in route + assert "import('../../views/widgets/WidgetsForm.vue')" in route + assert "import('../../views/widgets/WidgetsDetail.vue')" in route + + list_view = (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').read_text() + assert 'widgetsApi' in list_view + assert "api.get('/widgets'" in list_view + # no unresolved template placeholders for our known substitution keys + assert '${name}' not in list_view + assert '${Name}' not in list_view + + +def test_route_file_has_plugin_gating_and_requiresauth(tmp_path): + """Every route carries meta.plugin; form routes add requiresAuth.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + + route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text() + + # ADR-009 gating on every route + assert route.count("plugin: 'widgets'") == 4 + # form routes (new + edit) require auth; list + detail do not + assert route.count('requiresAuth: true') == 2 + assert "path: 'widgets/new'" in route + assert "path: 'widgets/:id/edit'" in route + + +def test_views_use_global_classes_not_custom_input_css(tmp_path): + """Views lean on global .filters / .form-control / .card classes and never + redefine input styling in a scoped block.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + + views = frontend_dir / 'views' / 'widgets' + list_view = (views / 'WidgetsList.vue').read_text() + form_view = (views / 'WidgetsForm.vue').read_text() + + assert 'class="filters"' in list_view + assert 'class="form-control"' in list_view + assert 'class="card"' in list_view + assert 'class="form-control"' in form_view + + # per-page input styling is forbidden: no scoped rule targets .form-control + # on its own (a descendant layout helper like `.filters .form-control` is ok) + import re + for view in (list_view, form_view): + assert not re.search(r'(?m)^\s*\.form-control\s*\{', view) + + +def test_default_frontend_dir_resolves_relative_to_plugins(tmp_path): + """With no frontend_dir arg, views land in /../frontend/src.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + + scaffold_plugin('widgets', 'Test', plugins_dir) + + assert (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').exists() + assert (frontend_dir / 'router' / 'routes' / 'widgets.js').exists() + + +def test_skip_when_no_frontend_dir(tmp_path): + """No frontend tree: views and route are skipped, backend still generated. + + The api snippet is still emitted because it is a plugin-directory artifact. + """ + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + missing_frontend = tmp_path / 'nowhere' / 'src' + + target = scaffold_plugin( + name='widgets', + description='Test', + plugins_dir=plugins_dir, + frontend_dir=missing_frontend, + ) + + # backend skeleton is intact + assert (target / 'manifest.json').exists() + assert (target / 'plugin.py').exists() + # frontend views and route were skipped + assert not missing_frontend.exists() + # snippet still lands in the plugin dir + assert (target / 'frontend-api-snippet.js').exists() + + +def test_frontend_can_be_disabled(tmp_path): + """frontend=False generates neither views, route, nor snippet.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + + target = scaffold_plugin( + name='widgets', + description='Test', + plugins_dir=plugins_dir, + frontend_dir=frontend_dir, + frontend=False, + ) + + assert not (frontend_dir / 'views' / 'widgets').exists() + assert not (frontend_dir / 'router' / 'routes' / 'widgets.js').exists() + assert not (target / 'frontend-api-snippet.js').exists() + + +def test_no_clobber_without_overwrite(tmp_path): + """An existing frontend view is left untouched unless overwrite is set.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + + # author already hand-edited a view before re-scaffolding + views_dir = frontend_dir / 'views' / 'widgets' + views_dir.mkdir(parents=True) + existing = views_dir / 'WidgetsList.vue' + existing.write_text('SENTINEL do not clobber') + + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + + # untouched without overwrite + assert existing.read_text() == 'SENTINEL do not clobber' + + +def test_overwrite_replaces_existing_frontend(tmp_path): + """overwrite=True regenerates an existing frontend view.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + + views_dir = frontend_dir / 'views' / 'widgets' + views_dir.mkdir(parents=True) + existing = views_dir / 'WidgetsList.vue' + existing.write_text('SENTINEL do not clobber') + + # plugin dir must also exist to reach overwrite path + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + scaffold_plugin( + 'widgets', 'Test', plugins_dir, + frontend_dir=frontend_dir, overwrite=True, + ) + + assert existing.read_text() != 'SENTINEL do not clobber' + assert 'widgetsApi' in existing.read_text() + + +def test_route_references_only_existing_views(tmp_path): + """Every view lazy-imported by the route file exists on disk. + + Guards the write-views-before-route ordering: a route pointing at a missing + view crashes the Vite dev server. + """ + plugins_dir, frontend_dir = _make_dirs(tmp_path) + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + + route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text() + import re + imported = re.findall(r"import\('(\.\./\.\./views/[^']+)'\)", route) + assert imported + for rel in imported: + # rel is relative to router/routes/; resolve against that dir + resolved = (frontend_dir / 'router' / 'routes' / rel).resolve() + assert resolved.exists(), f'route imports missing view: {rel}' + + +def test_generated_frontend_is_ascii(tmp_path): + """Generated frontend files contain plain ASCII only.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + target = scaffold_plugin( + 'widgets', 'Test', plugins_dir, frontend_dir=frontend_dir, + ) + + generated = [ + frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue', + frontend_dir / 'views' / 'widgets' / 'WidgetsDetail.vue', + frontend_dir / 'views' / 'widgets' / 'WidgetsForm.vue', + frontend_dir / 'router' / 'routes' / 'widgets.js', + target / 'frontend-api-snippet.js', + ] + for path in generated: + text = path.read_text() + text.encode('ascii') # raises if any non-ASCII slipped in + + +def test_frontend_dir_not_created_when_absent(tmp_path): + """Scaffolder does not fabricate a frontend tree that was not there.""" + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + + # no frontend dir exists; default resolves to /frontend/src (absent) + scaffold_plugin('widgets', 'Test', plugins_dir) + + assert not (tmp_path / 'frontend').exists() + + +def test_scaffold_still_raises_on_existing_plugin(tmp_path): + """Frontend generation does not weaken the plugin-dir overwrite guard.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + scaffold_plugin('widgets', 'first', plugins_dir, frontend_dir=frontend_dir) + + with pytest.raises(ScaffoldError, match='already exists'): + scaffold_plugin( + 'widgets', 'second', plugins_dir, frontend_dir=frontend_dir, + ) diff --git a/tests/test_plugins/__init__.py b/tests/test_plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_plugins/test_dualpath_single.py b/tests/test_plugins/test_dualpath_single.py new file mode 100644 index 00000000..551d4987 --- /dev/null +++ b/tests/test_plugins/test_dualpath_single.py @@ -0,0 +1,163 @@ +"""Dualpath single-machine collapse (site setting dualpath_single_machine). + +A Dualpath pair is one physical dual-bay machine recorded as two asset rows. +With the setting on (default), the machines list, dashboard machine count, and +the floor map collapse the pair to the PRIMARY bay (lower natural-sort +assetnumber) and annotate it with its hidden partner. With the setting off, +both bays list and count separately. The data model always keeps both rows. + +Settings are cached, so each test invalidates the cache after seeding. +""" + +from shopdb.extensions import db as _db +from shopdb.core.models import Asset, AssetType, Setting +from shopdb.core.models.relationship import RelationshipType, AssetRelationship +from shopdb.core.api.settings import invalidate_settings_cache + +from plugins.machines.models import Machine + + +def _seed_dualpath_pair(): + """Create a 'machine' AssetType, two machine assets (2007, 2008) placed on + the map, and an active Dualpath relationship between them. 2007 is PRIMARY + (lower natural-sort). Returns (primary_machine, secondary_machine).""" + machinetype = AssetType(assettype='machine') + _db.session.add(machinetype) + _db.session.flush() + + dualpath = RelationshipType(relationshiptype='Dualpath', isdirectional=False) + _db.session.add(dualpath) + _db.session.flush() + + primary_asset = Asset(assetnumber='2007', assettypeid=machinetype.assettypeid, + mapx=100, mapy=100) + secondary_asset = Asset(assetnumber='2008', assettypeid=machinetype.assettypeid, + mapx=110, mapy=110) + _db.session.add_all([primary_asset, secondary_asset]) + _db.session.flush() + + primary_machine = Machine(assetid=primary_asset.assetid) + secondary_machine = Machine(assetid=secondary_asset.assetid) + _db.session.add_all([primary_machine, secondary_machine]) + _db.session.flush() + + _db.session.add(AssetRelationship( + sourceassetid=primary_asset.assetid, + targetassetid=secondary_asset.assetid, + relationshiptypeid=dualpath.relationshiptypeid, + )) + _db.session.commit() + return primary_machine, secondary_machine + + +def _set_toggle(value): + Setting.set('dualpath_single_machine', value, valuetype='boolean', + category='site') + invalidate_settings_cache() + + +# --------------------------------------------------------------------------- +# Setting ON (default): pair collapses to the primary bay +# --------------------------------------------------------------------------- + +def test_list_collapses_secondary_and_annotates_primary(client, db): + invalidate_settings_cache() # default on (no row) + _seed_dualpath_pair() + + resp = client.get('/api/machines') + assert resp.status_code == 200, resp.get_json() + payload = resp.get_json() + items = payload['data'] + numbers = [i['assetnumber'] for i in items] + + # secondary bay (2008) is hidden; total reflects the collapse + assert numbers == ['2007'] + assert payload['meta']['pagination']['total'] == 1 + + # the visible primary carries its partner + partner = items[0]['dualpathpartner'] + assert partner is not None + assert partner['assetnumber'] == '2008' + assert partner['assetid'] is not None + assert partner['machineid'] is not None + + +def test_dashboard_machine_count_collapses(client, db): + invalidate_settings_cache() + _seed_dualpath_pair() + + resp = client.get('/api/dashboard') + assert resp.status_code == 200, resp.get_json() + counts = resp.get_json()['data'] + # two machine rows, one physical machine + assert counts['totalmachines'] == 1 + assert counts['counts']['machines'] == 1 + + +def test_map_excludes_secondary_marker(client, db): + invalidate_settings_cache() + _seed_dualpath_pair() + + resp = client.get('/api/assets/map') + assert resp.status_code == 200, resp.get_json() + assets = resp.get_json()['data']['assets'] + numbers = [a['assetnumber'] for a in assets] + + assert '2007' in numbers + assert '2008' not in numbers + primary = next(a for a in assets if a['assetnumber'] == '2007') + assert primary['dualpathpartner'] == '2008' + + +def test_detail_banner_partner_shown_regardless_of_toggle(client, db): + invalidate_settings_cache() + primary_machine, secondary_machine = _seed_dualpath_pair() + + # banner data is present on BOTH bays even with the toggle off + _set_toggle(False) + for machine_id, expected in ( + (primary_machine.machineid, '2008'), + (secondary_machine.machineid, '2007'), + ): + resp = client.get(f'/api/machines/{machine_id}') + assert resp.status_code == 200, resp.get_json() + partner = resp.get_json()['data']['dualpathpartner'] + assert partner is not None + assert partner['assetnumber'] == expected + + +# --------------------------------------------------------------------------- +# Setting OFF: both bays list and count separately +# --------------------------------------------------------------------------- + +def test_list_shows_both_bays_when_disabled(client, db): + _seed_dualpath_pair() + _set_toggle(False) + + resp = client.get('/api/machines') + assert resp.status_code == 200, resp.get_json() + payload = resp.get_json() + numbers = sorted(i['assetnumber'] for i in payload['data']) + + assert numbers == ['2007', '2008'] + assert payload['meta']['pagination']['total'] == 2 + + +def test_dashboard_counts_both_bays_when_disabled(client, db): + _seed_dualpath_pair() + _set_toggle(False) + + resp = client.get('/api/dashboard') + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['totalmachines'] == 2 + + +def test_map_shows_both_bays_when_disabled(client, db): + _seed_dualpath_pair() + _set_toggle(False) + + resp = client.get('/api/assets/map') + assert resp.status_code == 200, resp.get_json() + numbers = [a['assetnumber'] for a in resp.get_json()['data']['assets']] + assert '2007' in numbers + assert '2008' in numbers diff --git a/tests/test_plugins/test_employee_photo.py b/tests/test_plugins/test_employee_photo.py new file mode 100644 index 00000000..7de0142a --- /dev/null +++ b/tests/test_plugins/test_employee_photo.py @@ -0,0 +1,212 @@ +"""Tests for self-hosted employee photo upload, replace, delete, serve, and the +shared photo-URL resolver used by both EmployeeDetail and the kiosk cards. + +Covers: admin-gated upload/delete, the public serve route, one-photo-per-person +replace semantics, external-mode 409s, and resolve_employee_photo_url in both +directory modes. The wjf_employees HR DB is not available under test, so external +resolution is exercised by passing the Picture value directly (no query).""" + +import io +import os + +from shopdb.core.models import Setting +from plugins.employees.models import DirectoryEmployee +from plugins.employees.api.routes import ( + resolve_employee_photo_url, + EMPLOYEE_PHOTO_URL_PREFIX, + EMPLOYEE_PHOTO_STATIC_PREFIX, +) + + +def _set_mode(db, mode): + row = Setting.query.filter_by(key='employee_directory_mode').first() + if row: + row.value = mode + else: + db.session.add(Setting(key='employee_directory_mode', value=mode, + valuetype='string', category='site')) + db.session.commit() + + +def _make_employee(db, sso=210000001): + emp = DirectoryEmployee(sso=sso, firstname='Test', lastname='Person') + db.session.add(emp) + db.session.commit() + return emp + + +# --- upload / replace / delete (self-hosted) -------------------------------- + +def test_upload_forbidden_for_non_admin(client, db, member_headers): + """A role-less authenticated user cannot upload an employee photo.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + data = {'file': (io.BytesIO(b'bytes'), 'p.png')} + resp = client.post(f'/api/employees/{emp.sso}/photo', data=data, + content_type='multipart/form-data', headers=member_headers) + assert resp.status_code == 403 + assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN' + + +def test_upload_sets_photofilename_and_file_exists(client, db, auth_headers, app): + """Admin upload writes photofilename + photourl and lands the file on disk.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + payload = b'\x89PNG\r\n\x1a\nfake-png' + data = {'file': (io.BytesIO(payload), 'p.png')} + resp = client.post(f'/api/employees/{emp.sso}/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + + filename = f'photo-{emp.sso}.png' + body = resp.get_json()['data'] + assert body['photofilename'] == filename + assert body['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}{filename}' + + refreshed = db.session.get(DirectoryEmployee, emp.sso) + assert refreshed.photofilename == filename + + path = os.path.join(app.instance_path, 'employeephotos', filename) + assert os.path.exists(path) + with open(path, 'rb') as handle: + assert handle.read() == payload + + +def test_upload_rejects_invalid_extension(client, db, auth_headers): + """A disallowed file extension is rejected.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + data = {'file': (io.BytesIO(b'MZ'), 'p.exe')} + resp = client.post(f'/api/employees/{emp.sso}/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 400 + assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR' + + +def test_upload_missing_employee_is_404(client, db, auth_headers): + """Uploading to a nonexistent SSO is a 404.""" + _set_mode(db, 'selfhosted') + data = {'file': (io.BytesIO(b'x'), 'p.png')} + resp = client.post('/api/employees/999999/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 404 + + +def test_reupload_replaces_and_removes_old_extension(client, db, auth_headers, app): + """Re-upload with a different extension deletes the prior file.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + first = {'file': (io.BytesIO(b'first'), 'p.png')} + client.post(f'/api/employees/{emp.sso}/photo', data=first, + content_type='multipart/form-data', headers=auth_headers) + oldpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png') + assert os.path.exists(oldpath) + + second = {'file': (io.BytesIO(b'second'), 'p.jpg')} + resp = client.post(f'/api/employees/{emp.sso}/photo', data=second, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 200 + + assert not os.path.exists(oldpath) + newpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.jpg') + assert os.path.exists(newpath) + refreshed = db.session.get(DirectoryEmployee, emp.sso) + assert refreshed.photofilename == f'photo-{emp.sso}.jpg' + + +def test_serve_returns_bytes(client, db, auth_headers): + """The public serve route returns the uploaded bytes without auth.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + payload = b'\x89PNG\r\n\x1a\nserved' + data = {'file': (io.BytesIO(payload), 'p.png')} + client.post(f'/api/employees/{emp.sso}/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + + served = client.get(f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png') + assert served.status_code == 200 + assert served.get_data() == payload + + +def test_delete_clears_field_and_removes_file(client, db, auth_headers, app): + """Delete clears photofilename and removes the uploaded file.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + data = {'file': (io.BytesIO(b'bytes'), 'p.png')} + client.post(f'/api/employees/{emp.sso}/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + path = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png') + assert os.path.exists(path) + + resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=auth_headers) + assert resp.status_code == 200 + assert resp.get_json()['data']['photourl'] is None + assert not os.path.exists(path) + + refreshed = db.session.get(DirectoryEmployee, emp.sso) + assert refreshed.photofilename is None + + +def test_delete_forbidden_for_non_admin(client, db, member_headers): + """A role-less authenticated user cannot delete an employee photo.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=member_headers) + assert resp.status_code == 403 + + +# --- external-mode 409 ------------------------------------------------------ + +def test_upload_conflict_in_external_mode(client, db, auth_headers): + """Upload is a 409 when the directory is external (HR owns the photo).""" + _set_mode(db, 'external') + data = {'file': (io.BytesIO(b'x'), 'p.png')} + resp = client.post('/api/employees/210000001/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 409 + assert resp.get_json()['data']['error']['code'] == 'CONFLICT' + + +def test_delete_conflict_in_external_mode(client, db, auth_headers): + """Delete is a 409 when the directory is external.""" + _set_mode(db, 'external') + resp = client.delete('/api/employees/210000001/photo', headers=auth_headers) + assert resp.status_code == 409 + + +# --- resolver --------------------------------------------------------------- + +def test_resolver_selfhosted_returns_upload_url_or_none(client, db, auth_headers): + """Self-hosted: served upload URL when a photo exists, else None.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + assert resolve_employee_photo_url(emp.sso) is None + + data = {'file': (io.BytesIO(b'x'), 'p.png')} + client.post(f'/api/employees/{emp.sso}/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resolve_employee_photo_url(emp.sso) == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png' + + +def test_resolver_external_prefixes_relative_and_passes_urls(db): + """External: relative HR paths get the static prefix; absolute URLs pass through.""" + _set_mode(db, 'external') + assert resolve_employee_photo_url(210000001, 'Support/210000001.png') == \ + f'{EMPLOYEE_PHOTO_STATIC_PREFIX}Support/210000001.png' + assert resolve_employee_photo_url(210000001, 'https://hr.example.net/p.png') == \ + 'https://hr.example.net/p.png' + assert resolve_employee_photo_url(210000001, None) is None + assert resolve_employee_photo_url(None) is None + + +def test_lookup_includes_photourl(client, db, auth_headers): + """The lookup serializer carries the resolved photourl (self-hosted).""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + data = {'file': (io.BytesIO(b'x'), 'p.png')} + client.post(f'/api/employees/{emp.sso}/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + + resp = client.get(f'/api/employees/lookup/{emp.sso}') + assert resp.status_code == 200 + assert resp.get_json()['data']['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png' diff --git a/tests/test_plugins/test_geenforce_compliance.py b/tests/test_plugins/test_geenforce_compliance.py new file mode 100644 index 00000000..f4dc75cc --- /dev/null +++ b/tests/test_plugins/test_geenforce_compliance.py @@ -0,0 +1,166 @@ +"""GE-Enforce compliance: fleet-install coverage per app-linked entry. + +Compliance pairs each app-linked manifest entry with collected PC data +(ComputerInstalledApp) to answer 'how many PCs have this app, and how many on +the expected version'. Only entries carrying a curated appid appear; unlinked +entries are skipped. FileVersion detection supplies the expected-version target; +every other detection method yields a null target (no meaningful match). When +the optional computers plugin is absent, counts degrade to null gracefully. +""" + +from shopdb.core.models import Asset, AssetType, Application + +from plugins.geenforce import service + + +def _seed_app(db, appname): + app_row = Application(appname=appname) + db.session.add(app_row) + db.session.commit() + return app_row.appid + + +def _seed_pc_with_app(db, hostname, appid, installedversion, isactive=True): + """Create a Computer + one ComputerInstalledApp row for it.""" + from plugins.computers.models import Computer, ComputerInstalledApp + atype = AssetType.query.filter_by(assettype='computer').first() + if not atype: + atype = AssetType(assettype='computer') + db.session.add(atype) + db.session.flush() + asset = Asset(assetnumber=f'PC-{hostname}', assettypeid=atype.assettypeid) + db.session.add(asset) + db.session.flush() + comp = Computer(assetid=asset.assetid, hostname=hostname) + db.session.add(comp) + db.session.flush() + link = ComputerInstalledApp(computerid=comp.computerid, appid=appid, + installedversion=installedversion, + isactive=isactive) + db.session.add(link) + db.session.commit() + return comp + + +def _create_scope(client, auth_headers, name='gea-shopfloor-cmm'): + resp = client.post('/api/geenforce/scopes', + json={'scopename': name, 'phase': 'runtime'}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['scopeid'] + + +def _add_entry(client, auth_headers, scopeid, entry): + resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries', + json=entry, headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['entryid'] + + +def test_compliance_only_lists_app_linked_entries(client, db, auth_headers): + appid = _seed_app(db, 'PC-DMIS') + scopeid = _create_scope(client, auth_headers) + _add_entry(client, auth_headers, scopeid, + {'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid, + 'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe', + 'DetectionValue': '2019 R1'}) + # An unlinked entry (no appid) must NOT appear. + _add_entry(client, auth_headers, scopeid, + {'Name': 'Unlinked', 'Type': 'MSI', 'Installer': 'apps/x.msi'}) + + data = client.get(f'/api/geenforce/scopes/{scopeid}/compliance', + headers=auth_headers).get_json()['data'] + assert data['computersplugin'] is True + assert [r['entryname'] for r in data['rows']] == ['PC-DMIS 2019'] + row = data['rows'][0] + assert row['appid'] == appid + assert row['appname'] == 'PC-DMIS' + assert row['expectedversion'] == '2019 R1' + + +def test_compliance_counts_active_installs_and_version_match(client, db, + auth_headers): + appid = _seed_app(db, 'PC-DMIS') + # Two PCs on the expected version, one on a different version, one inactive. + _seed_pc_with_app(db, 'CMM01', appid, '2019 R1') + _seed_pc_with_app(db, 'CMM02', appid, '2019 R1') + _seed_pc_with_app(db, 'CMM03', appid, '2016 R2') + _seed_pc_with_app(db, 'CMM04', appid, '2019 R1', isactive=False) + + scopeid = _create_scope(client, auth_headers) + _add_entry(client, auth_headers, scopeid, + {'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid, + 'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe', + 'DetectionValue': '2019 R1'}) + + row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance', + headers=auth_headers).get_json()['data']['rows'][0] + # Inactive row excluded: 3 active installs, 2 on the expected version. + assert row['installedcount'] == 3 + assert row['versionmatchcount'] == 2 + assert '2 of 3' in row['coveragenote'] + + +def test_compliance_non_fileversion_has_null_version_target(client, db, + auth_headers): + appid = _seed_app(db, 'eDNC') + _seed_pc_with_app(db, 'DNC01', appid, '5.0') + scopeid = _create_scope(client, auth_headers) + # Registry detection is not a version string -> no expected-version target. + _add_entry(client, auth_headers, scopeid, + {'Name': 'eDNC', 'Type': 'MSI', 'appid': appid, + 'DetectionMethod': 'Registry', + 'DetectionPath': 'HKLM\\Software\\eDNC'}) + + row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance', + headers=auth_headers).get_json()['data']['rows'][0] + assert row['expectedversion'] is None + assert row['installedcount'] == 1 + assert row['versionmatchcount'] is None + assert 'no version target' in row['coveragenote'] + + +def test_compliance_not_installed_anywhere(client, db, auth_headers): + appid = _seed_app(db, 'Orphan') + scopeid = _create_scope(client, auth_headers) + _add_entry(client, auth_headers, scopeid, + {'Name': 'Orphan', 'Type': 'MSI', 'appid': appid, + 'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\o.exe', + 'DetectionValue': '1.0'}) + + row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance', + headers=auth_headers).get_json()['data']['rows'][0] + assert row['installedcount'] == 0 + assert row['versionmatchcount'] == 0 + assert row['coveragenote'] == 'not installed on any collected PC' + + +def test_compliance_scope_not_found(client, db, auth_headers): + resp = client.get('/api/geenforce/scopes/999999/compliance', + headers=auth_headers) + assert resp.status_code == 404 + + +def test_compliance_requires_manage_permission(client, db, member_headers): + resp = client.get('/api/geenforce/scopes/1/compliance', + headers=member_headers) + assert resp.status_code == 403 + + +def test_compliance_graceful_without_computers_plugin(client, db, auth_headers, + monkeypatch): + appid = _seed_app(db, 'PC-DMIS') + scopeid = _create_scope(client, auth_headers) + _add_entry(client, auth_headers, scopeid, + {'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid, + 'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe', + 'DetectionValue': '2019 R1'}) + monkeypatch.setattr(service, '_installed_app_model', lambda: None) + + data = client.get(f'/api/geenforce/scopes/{scopeid}/compliance', + headers=auth_headers).get_json()['data'] + assert data['computersplugin'] is False + row = data['rows'][0] + assert row['installedcount'] is None + assert row['versionmatchcount'] is None + assert 'computers plugin not installed' in row['coveragenote'] diff --git a/tests/test_plugins/test_geenforce_crud.py b/tests/test_plugins/test_geenforce_crud.py new file mode 100644 index 00000000..4448902c --- /dev/null +++ b/tests/test_plugins/test_geenforce_crud.py @@ -0,0 +1,278 @@ +"""GE-Enforce P2 admin CRUD: scopes, entries, reorder, simulate, publish cycle. + +All admin endpoints are JWT + geenforce.manage/publish gated (the admin user +holds both). Entry payloads use the manifest Applications[] shape. +""" + + +def _create_scope(client, auth_headers, name='gea-shopfloor-test'): + resp = client.post('/api/geenforce/scopes', + json={'scopename': name, 'phase': 'runtime', + 'description': 'test scope'}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['scopeid'] + + +def _add_entry(client, auth_headers, scopeid, entry): + resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries', + json=entry, headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['entryid'] + + +def test_scope_crud(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + + got = client.get(f'/api/geenforce/scopes/{scopeid}', headers=auth_headers) + assert got.status_code == 200 + assert got.get_json()['data']['scopename'] == 'gea-shopfloor-test' + + upd = client.put(f'/api/geenforce/scopes/{scopeid}', + json={'description': 'updated', 'computertypeid': 3}, + headers=auth_headers) + assert upd.get_json()['data']['computertypeid'] == 3 + + dele = client.delete(f'/api/geenforce/scopes/{scopeid}', headers=auth_headers) + assert dele.status_code == 200 + assert client.get(f'/api/geenforce/scopes/{scopeid}', + headers=auth_headers).status_code == 404 + + +def test_duplicate_scope_rejected(client, db, auth_headers): + _create_scope(client, auth_headers, 'gea-shopfloor-dup') + resp = client.post('/api/geenforce/scopes', + json={'scopename': 'gea-shopfloor-dup', 'phase': 'runtime'}, + headers=auth_headers) + assert resp.status_code == 400 + + +def test_entry_crud_and_reorder(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + a = _add_entry(client, auth_headers, scopeid, + {'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/a.msi'}) + b = _add_entry(client, auth_headers, scopeid, + {'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/b.ps1'}) + + # Update Alpha: change type + add detection. + upd = client.put(f'/api/geenforce/entries/{a}', + json={'Name': 'Alpha', 'Type': 'EXE', 'Installer': 'apps/a.exe', + 'DetectionMethod': 'FileVersion', + 'DetectionPath': 'C:\\a.exe', 'DetectionValue': '1.0'}, + headers=auth_headers) + assert upd.status_code == 200 + assert upd.get_json()['data']['Type'] == 'EXE' + assert upd.get_json()['data']['DetectionMethod'] == 'FileVersion' + + # Reorder: Beta first. + reo = client.put(f'/api/geenforce/scopes/{scopeid}/entries/reorder', + json={'order': [b, a]}, headers=auth_headers) + assert reo.status_code == 200 + entries = client.get(f'/api/geenforce/scopes/{scopeid}', + headers=auth_headers).get_json()['data']['entries'] + assert [e['Name'] for e in entries] == ['Beta', 'Alpha'] + + # Delete Beta. + assert client.delete(f'/api/geenforce/entries/{b}', + headers=auth_headers).status_code == 200 + entries = client.get(f'/api/geenforce/scopes/{scopeid}', + headers=auth_headers).get_json()['data']['entries'] + assert [e['Name'] for e in entries] == ['Alpha'] + + +def test_entry_edit_preserves_full_fidelity(client, db, auth_headers): + """Editing an entry with the complete payload keeps InUseCheck ExePath + + timeout, LogFile, and preinstall flags (the UI now sends all of them).""" + scopeid = _create_scope(client, auth_headers) + rich = { + 'Name': 'PC-DMIS', 'Type': 'MSI', 'Installer': 'apps/pcdmis.msi', + 'LogFile': 'C:\\Logs\\pcdmis.log', + 'PreEnrollment': True, 'PCTypesStrict': True, + 'InUseCheck': {'Behavior': 'CloseAndReopen', 'Processes': [ + {'Name': 'PCDLRN', 'ExePath': 'C:\\PCDLRN.exe', + 'GracefulCloseTimeoutSec': 15}]}, + } + entryid = _add_entry(client, auth_headers, scopeid, rich) + + # Re-send the same full payload (what the editor now does) and confirm + # nothing is stripped. + upd = client.put(f'/api/geenforce/entries/{entryid}', json=rich, + headers=auth_headers) + assert upd.status_code == 200 + entry = upd.get_json()['data'] + assert entry['LogFile'] == 'C:\\Logs\\pcdmis.log' + assert entry['PreEnrollment'] is True + assert entry['PCTypesStrict'] is True + proc = entry['InUseCheck']['Processes'][0] + assert proc['ExePath'] == 'C:\\PCDLRN.exe' + assert proc['GracefulCloseTimeoutSec'] == 15 + + +def test_entry_curated_app_link(client, db, auth_headers): + """An entry can be linked to an existing Application; the link is shopdb + metadata and must NOT leak into the manifest JSON (parity/enforcement).""" + from shopdb.core.models import Application + app_row = Application(appname='eDNC (tracked)') + db.session.add(app_row) + db.session.commit() + appid = app_row.appid + + scopeid = _create_scope(client, auth_headers) + created = client.post(f'/api/geenforce/scopes/{scopeid}/entries', + json={'Name': 'eDNC install', 'Type': 'MSI', + 'appid': appid}, headers=auth_headers) + assert created.status_code == 201, created.get_json() + data = created.get_json()['data'] + assert data['appid'] == appid + assert data['appname'] == 'eDNC (tracked)' + + # the link is NOT in the rendered manifest (would break the engine contract) + manifest = client.get(f'/api/geenforce/scopes/{scopeid}/preview', + headers=auth_headers).get_json()['data']['manifest'] + entry_json = manifest['Applications'][0] + assert 'appid' not in entry_json and 'appname' not in entry_json + + # unlink + updated = client.put(f"/api/geenforce/entries/{data['entryid']}", + json={'Name': 'eDNC install', 'Type': 'MSI', 'appid': None}, + headers=auth_headers) + assert updated.get_json()['data']['appid'] is None + + # picker lists the app + picker = client.get('/api/geenforce/applications', + headers=auth_headers).get_json()['data'] + assert any(a['appname'] == 'eDNC (tracked)' for a in picker) + + +def test_entry_nonnumeric_appid_ignored(client, db, auth_headers): + """A non-numeric appid must be ignored (link stays null), not 500. Matches + the _apply_app_link docstring contract.""" + scopeid = _create_scope(client, auth_headers) + created = client.post(f'/api/geenforce/scopes/{scopeid}/entries', + json={'Name': 'x', 'Type': 'MSI', 'appid': 'notanumber'}, + headers=auth_headers) + assert created.status_code == 201, created.get_json() + assert created.get_json()['data']['appid'] is None + + +def test_invalid_entry_type_rejected(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries', + json={'Name': 'Bad', 'Type': 'NOTATYPE'}, + headers=auth_headers) + assert resp.status_code == 400 + + +def test_reorder_rejects_foreign_ids(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + a = _add_entry(client, auth_headers, scopeid, + {'Name': 'Alpha', 'Type': 'MSI'}) + resp = client.put(f'/api/geenforce/scopes/{scopeid}/entries/reorder', + json={'order': [a, 9999]}, headers=auth_headers) + assert resp.status_code == 400 + + +def test_simulate_cmm_version_gate(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-cmm') + _add_entry(client, auth_headers, scopeid, + {'Name': 'CLM (untagged)', 'Type': 'MSI', 'Installer': 'apps/clm.msi'}) + _add_entry(client, auth_headers, scopeid, + {'Name': 'PC-DMIS 2016', 'Type': 'MSI', 'Installer': 'apps/16.msi', + '_CmmVersion': '2016'}) + _add_entry(client, auth_headers, scopeid, + {'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'Installer': 'apps/19.msi', + '_CmmVersion': '2019'}) + + sim = client.get(f'/api/geenforce/scopes/{scopeid}/simulate?cmmversion=2019', + headers=auth_headers) + assert sim.status_code == 200 + data = sim.get_json()['data'] + assert data['applied'] == ['CLM (untagged)', 'PC-DMIS 2019'] + filtered = {f['name']: f['filteredby'] for f in data['filtered']} + assert filtered['PC-DMIS 2016'] == ['_CmmVersion'] + + +def test_duplicate_entry_name_is_400_not_500(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + _add_entry(client, auth_headers, scopeid, {'Name': 'Alpha', 'Type': 'MSI'}) + dup = client.post(f'/api/geenforce/scopes/{scopeid}/entries', + json={'Name': 'Alpha', 'Type': 'PS1'}, headers=auth_headers) + assert dup.status_code == 400 + + +def test_simulate_pctypesstrict_disables_alias(client, db, auth_headers): + """A collections-only strict entry must NOT match a nocollections PC via the + shared Standard alias group. PCTypesStrict is preinstall-only, so the scope + must be the preinstall phase (the runtime engine ignores strict).""" + resp = client.post('/api/geenforce/scopes', + json={'scopename': 'preinstall', 'phase': 'preinstall'}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + scopeid = resp.get_json()['data']['scopeid'] + _add_entry(client, auth_headers, scopeid, + {'Name': 'UDC (strict)', 'Type': 'EXE', 'Installer': 'apps/udc.exe', + 'PCTypes': ['gea-shopfloor-collections'], 'PCTypesStrict': True}) + _add_entry(client, auth_headers, scopeid, + {'Name': 'UDC (loose)', 'Type': 'EXE', 'Installer': 'apps/udc.exe', + 'PCTypes': ['gea-shopfloor-collections']}) + + sim = client.get( + f'/api/geenforce/scopes/{scopeid}/simulate?pctype=gea-shopfloor-nocollections', + headers=auth_headers).get_json()['data'] + # loose entry matches via the Standard alias group; strict entry does not. + assert sim['applied'] == ['UDC (loose)'] + filtered = {f['name']: f['filteredby'] for f in sim['filtered']} + assert filtered['UDC (strict)'] == ['PCTypes'] + + +def test_simulate_machine_number_gate(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-collections') + _add_entry(client, auth_headers, scopeid, + {'Name': 'Okuma cred', 'Type': 'PS1', 'Script': 'scripts/o.ps1', + 'TargetMachineNumbers': ['3201', '3202']}) + _add_entry(client, auth_headers, scopeid, + {'Name': 'Everyone', 'Type': 'PS1', 'Script': 'scripts/e.ps1'}) + + match = client.get( + f'/api/geenforce/scopes/{scopeid}/simulate?machinenumber=3201', + headers=auth_headers).get_json()['data'] + assert set(match['applied']) == {'Okuma cred', 'Everyone'} + + nomatch = client.get( + f'/api/geenforce/scopes/{scopeid}/simulate?machinenumber=9999', + headers=auth_headers).get_json()['data'] + assert nomatch['applied'] == ['Everyone'] + + +def test_publish_versions_rollback(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-cmm') + _add_entry(client, auth_headers, scopeid, {'Name': 'Alpha', 'Type': 'MSI'}) + + pub1 = client.post(f'/api/geenforce/scopes/{scopeid}/publish', + json={'notes': 'v1'}, headers=auth_headers) + assert pub1.status_code == 201 + assert pub1.get_json()['data']['versionnumber'] == 1 + + _add_entry(client, auth_headers, scopeid, {'Name': 'Beta', 'Type': 'PS1'}) + pub2 = client.post(f'/api/geenforce/scopes/{scopeid}/publish', + json={'notes': 'v2'}, headers=auth_headers) + assert pub2.get_json()['data']['versionnumber'] == 2 + + versions = client.get(f'/api/geenforce/scopes/{scopeid}/versions', + headers=auth_headers).get_json()['data'] + assert [v['versionnumber'] for v in versions] == [2, 1] + assert versions[0]['iscurrent'] is True + + # v2 manifest has both entries. + v2doc = client.get(f'/api/geenforce/scopes/{scopeid}/versions/2', + headers=auth_headers).get_json()['data']['manifest'] + assert [e['Name'] for e in v2doc['Applications']] == ['Alpha', 'Beta'] + + # Rollback to v1. + rb = client.post(f'/api/geenforce/scopes/{scopeid}/rollback', + json={'versionnumber': 1}, headers=auth_headers) + assert rb.status_code == 200 + versions = client.get(f'/api/geenforce/scopes/{scopeid}/versions', + headers=auth_headers).get_json()['data'] + current = next(v for v in versions if v['iscurrent']) + assert current['versionnumber'] == 1 diff --git a/tests/test_plugins/test_geenforce_ddl_parity.py b/tests/test_plugins/test_geenforce_ddl_parity.py new file mode 100644 index 00000000..47cd51b3 --- /dev/null +++ b/tests/test_plugins/test_geenforce_ddl_parity.py @@ -0,0 +1,134 @@ +"""GE-Enforce DDL parity gate: models match the hand-written Alembic baseline. + +The geenforce baseline (0001_geenforce_baseline.py) actually CREATES the plugin +tables (ADR-008 per-plugin chain), and it is hand-written - the models and the +migration can drift silently (a column added to a model but not the migration +ships a schema that upgrade() never builds, and vice versa). This guard builds +the schema two ways and diffs them column-by-column: + + 1. the SQLAlchemy models via metadata.create_all (desired shape), and + 2. the migration's upgrade() run against a fresh engine (shipped shape). + +Both go through the same SQLite dialect, so the reflected column types render +identically and the comparison stays low-flake. A drift (missing column, changed +type, changed nullability) fails here instead of on a real deploy insert. +""" + +import importlib.util +from pathlib import Path + +from sqlalchemy import create_engine, inspect +from alembic.migration import MigrationContext +from alembic.operations import Operations + + +_BASELINE = (Path(__file__).resolve().parent.parent.parent / 'plugins' / + 'geenforce' / 'migrations' / 'versions' / + '0001_geenforce_baseline.py') + + +def _geenforce_models(): + from plugins.geenforce.models import manifest + return [ + manifest.ManifestScope, + manifest.ManifestEntry, + manifest.ManifestEntryPcType, + manifest.ManifestEntryHostname, + manifest.ManifestEntryMachineNumber, + manifest.ManifestInUseCheck, + manifest.ManifestInUseCheckProcess, + manifest.ManifestPublishedVersion, + manifest.ManifestPayload, + manifest.ManifestEnforcementReport, + manifest.ManifestEnforcementResult, + manifest.PcTypeAlias, + ] + + +def _model_schema(db, models): + """Build the geenforce tables from the models; return an inspector.""" + engine = create_engine('sqlite://') + db.metadata.create_all(engine, tables=[model.__table__ for model in models]) + return inspect(engine) + + +def _migration_schema(): + """Run the baseline migration's upgrade() on a fresh engine; return columns. + + Returns {tablename: {columnname: reflected-column-dict}}. Reflection happens + while the connection is open, so we materialize the dicts before it closes. + """ + spec = importlib.util.spec_from_file_location('geenforce_baseline', + str(_BASELINE)) + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + + engine = create_engine('sqlite://') + schema = {} + with engine.connect() as connection: + operations = Operations(MigrationContext.configure(connection)) + operations._install_proxy() + try: + migration.upgrade() + finally: + operations._remove_proxy() + connection.commit() + inspector = inspect(connection) + for table in inspector.get_table_names(): + schema[table] = {column['name']: column + for column in inspector.get_columns(table)} + return schema + + +def _colkey(column): + """Normalize a reflected column to the facets we gate on.""" + return (str(column['type']), bool(column['nullable'])) + + +def test_models_match_migration_baseline(app, db): + """Every geenforce model table+column matches what the migration builds.""" + with app.app_context(): + models = _geenforce_models() + model_inspector = _model_schema(db, models) + migration_schema = _migration_schema() + + drift = [] + for model in models: + table = model.__tablename__ + if table not in migration_schema: + drift.append(f'{table}: table missing from migration') + continue + modelcols = {column['name']: column + for column in model_inspector.get_columns(table)} + migrationcols = migration_schema[table] + + missing = set(modelcols) - set(migrationcols) + extra = set(migrationcols) - set(modelcols) + for name in sorted(missing): + drift.append(f'{table}.{name}: in model, not in migration') + for name in sorted(extra): + drift.append(f'{table}.{name}: in migration, not in model') + for name in sorted(set(modelcols) & set(migrationcols)): + if _colkey(modelcols[name]) != _colkey(migrationcols[name]): + drift.append( + f'{table}.{name}: model {_colkey(modelcols[name])} != ' + f'migration {_colkey(migrationcols[name])}') + + assert not drift, 'model/migration DDL drift:\n' + '\n'.join(drift) + + +def test_curated_and_payload_columns_present_both_sides(app, db): + """Spot-guard the two real-work columns: manifestentries.appid and + manifestpayloads.payloadsha256 exist in BOTH the models and the migration + (they were added for the app-link + inline-payload features).""" + with app.app_context(): + models = _geenforce_models() + model_inspector = _model_schema(db, models) + migration_schema = _migration_schema() + + for table, column in (('manifestentries', 'appid'), + ('manifestpayloads', 'payloadsha256')): + modelcols = {c['name'] for c in model_inspector.get_columns(table)} + assert column in modelcols, f'{table}.{column} missing from models' + assert column in migration_schema[table], \ + f'{table}.{column} missing from migration' diff --git a/tests/test_plugins/test_geenforce_export.py b/tests/test_plugins/test_geenforce_export.py new file mode 100644 index 00000000..d020bb14 --- /dev/null +++ b/tests/test_plugins/test_geenforce_export.py @@ -0,0 +1,65 @@ +"""GE-Enforce export-to-share: config the share root, publish, write to share. + +This is the Milestone 1 loop - author + publish in shopdb, then export the +published JSON to the on-share manifest with a history backup, engine untouched. +""" + +import json +import os + +from plugins.geenforce import service + + +def _scope_with_entry(app, name='gea-shopfloor-cmm'): + with app.app_context(): + service.replace_scope_draft(name, 'runtime', { + 'Version': '2.6', + 'Applications': [{'Name': 'Alpha', 'Type': 'MSI', + 'Installer': 'apps/a.msi'}]}) + service.publish_scope(name, 'runtime', notes='v1') + scope = service.ManifestScope.query.filter_by(scopename=name).first() + scopeid = scope.scopeid + service.db.session.commit() + return scopeid + + +def test_export_requires_config(client, db, app, auth_headers): + scopeid = _scope_with_entry(app) + resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share', + headers=auth_headers) + assert resp.status_code == 400 # no share root configured + + +def test_config_roundtrip_and_export(client, db, app, auth_headers, tmp_path): + scopeid = _scope_with_entry(app) + + # Configure the share root. + put = client.put('/api/geenforce/config', + json={'shareroot': str(tmp_path)}, headers=auth_headers) + assert put.status_code == 200 + got = client.get('/api/geenforce/config', headers=auth_headers) + assert got.get_json()['data']['shareroot'] == str(tmp_path) + + # Export writes the published JSON to //manifest.json. + resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share', + headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + path = resp.get_json()['data']['path'] + assert os.path.isfile(path) + written = json.load(open(path)) + assert [e['Name'] for e in written['Applications']] == ['Alpha'] + + +def test_export_backs_up_existing(client, db, app, auth_headers, tmp_path): + scopeid = _scope_with_entry(app) + client.put('/api/geenforce/config', json={'shareroot': str(tmp_path)}, + headers=auth_headers) + # First export creates the file. + client.post(f'/api/geenforce/scopes/{scopeid}/export-share', + headers=auth_headers) + # Second export backs the old file into _meta/history. + client.post(f'/api/geenforce/scopes/{scopeid}/export-share', + headers=auth_headers) + historydir = tmp_path / '_meta' / 'history' + assert historydir.is_dir() + assert any(historydir.iterdir()) diff --git a/tests/test_plugins/test_geenforce_manifest.py b/tests/test_plugins/test_geenforce_manifest.py new file mode 100644 index 00000000..99e4efd5 --- /dev/null +++ b/tests/test_plugins/test_geenforce_manifest.py @@ -0,0 +1,152 @@ +"""GE-Enforce first slice: import a scope, publish it, serve it to a client. + +Exercises the vertical the plan calls the first slice (via a small synthetic +scope rather than the real gea-shopfloor-cmm): draft import -> publish -> +GET /api/geenforce/manifest with a geenforce.fetch service token; ETag/304; +draft edits never change the served bytes; publish + rollback change them. +""" + +import json + +import pytest + +from plugins.geenforce.models import ManifestScope +from plugins.geenforce import service + + +SCOPE = { + 'Version': '2.6', + 'Applications': [ + {'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/alpha.msi', + 'DetectionMethod': 'Registry', 'DetectionPath': 'HKLM:\\SOFTWARE\\Alpha'}, + {'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/beta.ps1', + 'DetectionMethod': 'Always'}, + ], +} + + +def _seed_and_publish(app, scopename='gea-shopfloor-cmm', manifest=None): + with app.app_context(): + service.replace_scope_draft(scopename, 'runtime', manifest or SCOPE) + service.publish_scope(scopename, 'runtime', notes='initial') + service.db.session.commit() + + +def _mint_fetch_token(client, auth_headers): + resp = client.post('/api/apitokens', + json={'name': 'geenforce svc', 'scopes': ['geenforce.fetch']}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['secret'] + + +def test_import_publish_serve(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _mint_fetch_token(client, auth_headers) + + resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}) + assert resp.status_code == 200, resp.data + body = json.loads(resp.data) + assert [e['Name'] for e in body['Applications']] == ['Alpha', 'Beta'] + assert resp.headers.get('ETag') + assert resp.headers.get('X-Manifest-Version') == '1' + + +def test_etag_304(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _mint_fetch_token(client, auth_headers) + first = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}) + etag = first.headers['ETag'] + again = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret, 'If-None-Match': etag}) + assert again.status_code == 304 + + +def test_draft_edit_does_not_change_served_bytes(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _mint_fetch_token(client, auth_headers) + before = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}).data + + # Edit the DRAFT (add an entry) but do NOT publish. + changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [ + {'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]} + with app.app_context(): + service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed) + service.db.session.commit() + + after = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}).data + assert before == after # served bytes come from the published snapshot only + + +def test_publish_then_rollback(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _mint_fetch_token(client, auth_headers) + v1 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}).data + + changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [ + {'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]} + with app.app_context(): + service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed) + service.publish_scope('gea-shopfloor-cmm', 'runtime', notes='add gamma') + service.db.session.commit() + + v2 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}) + assert v2.headers['X-Manifest-Version'] == '2' + assert b'Gamma' in v2.data + + with app.app_context(): + service.rollback_scope('gea-shopfloor-cmm', 'runtime', 1) + service.db.session.commit() + + rolled = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}) + assert rolled.data == v1 # byte-identical: published snapshots are frozen text + + +def test_unauthenticated_rejected(client, db, app): + _seed_and_publish(app) + resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm') + assert resp.status_code == 401 + + +def test_wrong_scope_rejected(client, db, app, auth_headers): + _seed_and_publish(app) + resp = client.post('/api/apitokens', + json={'name': 'wrong', 'scopes': ['collector.ingest']}, + headers=auth_headers) + secret = resp.get_json()['data']['secret'] + manifest = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}) + assert manifest.status_code == 401 + + +def test_unpublished_scope_404(client, db, app, auth_headers): + with app.app_context(): + service.replace_scope_draft('gea-shopfloor-keyence', 'runtime', SCOPE) + service.db.session.commit() # draft only, never published + secret = _mint_fetch_token(client, auth_headers) + resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-keyence', + headers={'X-API-Key': secret}) + assert resp.status_code == 404 + + +def test_admin_list_and_preview(client, db, app, auth_headers): + _seed_and_publish(app) + listing = client.get('/api/geenforce/scopes', headers=auth_headers) + assert listing.status_code == 200 + scopes = listing.get_json()['data'] + cmm = next(s for s in scopes if s['scopename'] == 'gea-shopfloor-cmm') + assert cmm['entrycount'] == 2 + assert cmm['publishedversion'] == 1 + + preview = client.get(f"/api/geenforce/scopes/{cmm['scopeid']}/preview", + headers=auth_headers) + assert preview.status_code == 200 + assert [e['Name'] for e in preview.get_json()['data']['manifest']['Applications']] \ + == ['Alpha', 'Beta'] diff --git a/tests/test_plugins/test_geenforce_payload.py b/tests/test_plugins/test_geenforce_payload.py new file mode 100644 index 00000000..ccdbc91f --- /dev/null +++ b/tests/test_plugins/test_geenforce_payload.py @@ -0,0 +1,125 @@ +"""GE-Enforce inline payloads: upload small scripts/configs into shopdb. + +An entry can carry one inline payload (<= 1 MB) stored as ManifestPayload bytes. +Upload computes a sha256, sets the entry's payloadsource='inline' + payloadref, +and enforces one payload row per entry (re-upload replaces). GET returns the +raw bytes. Upload needs geenforce.publish; GET needs geenforce.manage. appid and +the payload metadata are shopdb-only and never enter the served manifest JSON. +""" + +import hashlib +import io + +from plugins.geenforce.models import ManifestPayload + + +def _create_scope(client, auth_headers, name='gea-shopfloor-cmm'): + resp = client.post('/api/geenforce/scopes', + json={'scopename': name, 'phase': 'runtime'}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['scopeid'] + + +def _add_entry(client, auth_headers, scopeid, name='eDNC install'): + resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries', + json={'Name': name, 'Type': 'PS1', + 'Script': 'scripts/x.ps1'}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['entryid'] + + +def _upload(client, headers, entryid, content, filename='config.reg', + content_type='multipart/form-data'): + return client.post( + f'/api/geenforce/entries/{entryid}/payload', + data={'file': (io.BytesIO(content), filename)}, + content_type=content_type, headers=headers) + + +def test_upload_stores_payload_and_links_entry(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + entryid = _add_entry(client, auth_headers, scopeid) + content = b'Windows Registry Editor Version 5.00\r\n' + + resp = _upload(client, auth_headers, entryid, content) + assert resp.status_code == 201, resp.get_json() + data = resp.get_json()['data'] + assert data['payloadsource'] == 'inline' + assert data['payloadref'] == 'config.reg' + assert data['payloadsha256'] == hashlib.sha256(content).hexdigest() + assert data['haspayload'] is True + + rows = ManifestPayload.query.filter_by(entryid=entryid).all() + assert len(rows) == 1 + assert rows[0].payloadbytes == content + + +def test_reupload_replaces_single_row(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + entryid = _add_entry(client, auth_headers, scopeid) + _upload(client, auth_headers, entryid, b'first') + newcontent = b'second version' + resp = _upload(client, auth_headers, entryid, newcontent) + assert resp.status_code == 201 + + rows = ManifestPayload.query.filter_by(entryid=entryid).all() + assert len(rows) == 1 + assert rows[0].payloadbytes == newcontent + assert rows[0].payloadsha256 == hashlib.sha256(newcontent).hexdigest() + + +def test_oversized_payload_rejected(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + entryid = _add_entry(client, auth_headers, scopeid) + toobig = b'x' * (1024 * 1024 + 1) + resp = _upload(client, auth_headers, entryid, toobig) + assert resp.status_code == 400 + assert ManifestPayload.query.filter_by(entryid=entryid).count() == 0 + + +def test_empty_and_missing_file_rejected(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + entryid = _add_entry(client, auth_headers, scopeid) + empty = _upload(client, auth_headers, entryid, b'') + assert empty.status_code == 400 + nofile = client.post(f'/api/geenforce/entries/{entryid}/payload', + data={}, content_type='multipart/form-data', + headers=auth_headers) + assert nofile.status_code == 400 + + +def test_download_returns_exact_bytes(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + entryid = _add_entry(client, auth_headers, scopeid) + content = b'\x00\x01binary payload\xff' + _upload(client, auth_headers, entryid, content, filename='blob.bin') + + resp = client.get(f'/api/geenforce/entries/{entryid}/payload', + headers=auth_headers) + assert resp.status_code == 200 + assert resp.data == content + assert 'blob.bin' in resp.headers['Content-Disposition'] + + +def test_download_404_when_no_payload(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + entryid = _add_entry(client, auth_headers, scopeid) + resp = client.get(f'/api/geenforce/entries/{entryid}/payload', + headers=auth_headers) + assert resp.status_code == 404 + + +def test_upload_404_unknown_entry(client, db, auth_headers): + resp = _upload(client, auth_headers, 999999, b'data') + assert resp.status_code == 404 + + +def test_upload_requires_publish_permission(client, db, auth_headers, + member_headers): + scopeid = _create_scope(client, auth_headers) + entryid = _add_entry(client, auth_headers, scopeid) + # member (no permissions) cannot upload. + resp = _upload(client, member_headers, entryid, b'data') + assert resp.status_code == 403 diff --git a/tests/test_plugins/test_geenforce_reporting.py b/tests/test_plugins/test_geenforce_reporting.py new file mode 100644 index 00000000..4ed2d994 --- /dev/null +++ b/tests/test_plugins/test_geenforce_reporting.py @@ -0,0 +1,183 @@ +"""GE-Enforce observed-state reporting: PCs POST enforcement results. + +Covers the report ingest (applied version + per-entry self-heal outcomes), the +latest-per-host upsert, received-latest derivation (did the PC pick up the newest +published manifest?), status derivation (ok / selfhealed / failed), and the admin +fleet-compliance views. +""" + +import json + +from plugins.geenforce import service + + +SCOPE = { + 'Version': '2.6', + 'Applications': [ + {'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/alpha.msi'}, + {'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/beta.ps1'}, + ], +} + + +def _seed_and_publish(app, scopename='gea-shopfloor-cmm'): + with app.app_context(): + service.replace_scope_draft(scopename, 'runtime', SCOPE) + version = service.publish_scope(scopename, 'runtime', notes='v') + service.db.session.commit() + return version + + +def _token(client, auth_headers, scopes): + resp = client.post('/api/apitokens', + json={'name': 'svc', 'scopes': scopes}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['secret'] + + +def test_report_malformed_counts_is_400(client, db, app, auth_headers): + """A wrong JSON shape (counts as a string, results as a string) must be a + clean 400, not a 500 from a later .get()/iteration.""" + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + for bad in ({'counts': 'nope'}, {'results': 'nope'}): + body = {'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm'} + body.update(bad) + resp = client.post('/api/geenforce/report', json=body, + headers={'X-API-Key': secret}) + assert resp.status_code == 400, resp.get_json() + + +def test_report_recorded_and_listed(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + resp = client.post('/api/geenforce/report', json={ + 'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm', + 'appliedversion': 1, 'enforcerversion': '2.6', + 'counts': {'installed': 1, 'skipped': 1, 'failed': 0, 'filtered': 0}, + 'results': [ + {'name': 'Alpha', 'action': 'installed', 'selfhealed': True}, + {'name': 'Beta', 'action': 'skipped'}, + ], + }, headers={'X-API-Key': secret}) + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['status'] == 'selfhealed' + + listing = client.get('/api/geenforce/reports', headers=auth_headers) + rows = listing.get_json()['data'] + assert len(rows) == 1 + row = rows[0] + assert row['hostname'] == 'WJCMM01' + assert row['appliedversion'] == 1 + assert row['latestversion'] == 1 + assert row['receivedlatest'] is True + assert row['installed'] == 1 and row['skipped'] == 1 + + +def test_received_latest_flips_when_new_version_published(client, db, app, + auth_headers): + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + client.post('/api/geenforce/report', + json={'hostname': 'WJCMM02', 'scopename': 'gea-shopfloor-cmm', + 'appliedversion': 1, 'counts': {}}, + headers={'X-API-Key': secret}) + # Publish a newer version; the PC is now behind. + with app.app_context(): + service.publish_scope('gea-shopfloor-cmm', 'runtime', notes='v2') + service.db.session.commit() + + row = client.get('/api/geenforce/reports?hostname=WJCMM02', + headers=auth_headers).get_json()['data'][0] + assert row['appliedversion'] == 1 + assert row['latestversion'] == 2 + assert row['receivedlatest'] is False + + +def test_latest_report_upserts_per_host(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + for installed in (0, 1): + client.post('/api/geenforce/report', + json={'hostname': 'WJCMM03', 'scopename': 'gea-shopfloor-cmm', + 'appliedversion': 1, + 'counts': {'installed': installed}}, + headers={'X-API-Key': secret}) + rows = client.get('/api/geenforce/reports?hostname=WJCMM03', + headers=auth_headers).get_json()['data'] + assert len(rows) == 1 # only the latest is current + assert rows[0]['installed'] == 1 + + +def test_hostname_case_variation_keeps_single_current(client, db, app, auth_headers): + """A PC reporting its hostname in different casing must not leave two + iscurrent rows (the demote is case-insensitive, matching the ilike read).""" + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + for host in ('WJCMM09', 'wjcmm09'): + client.post('/api/geenforce/report', + json={'hostname': host, 'scopename': 'gea-shopfloor-cmm', + 'appliedversion': 1, 'counts': {'installed': 1}}, + headers={'X-API-Key': secret}) + from plugins.geenforce.models import ManifestEnforcementReport + with app.app_context(): + current = ManifestEnforcementReport.query.filter( + ManifestEnforcementReport.hostname.ilike('WJCMM09'), + ManifestEnforcementReport.iscurrent == True).all() + assert len(current) == 1 + + +def test_status_failed_when_failures(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + resp = client.post('/api/geenforce/report', + json={'hostname': 'WJCMM04', 'scopename': 'gea-shopfloor-cmm', + 'counts': {'failed': 1}, + 'results': [{'name': 'Alpha', 'action': 'failed', + 'exitcode': 1603, + 'message': 'MSI 1603'}]}, + headers={'X-API-Key': secret}) + assert resp.get_json()['data']['status'] == 'failed' + + +def test_report_detail_shows_per_entry_results(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + post = client.post('/api/geenforce/report', + json={'hostname': 'WJCMM05', 'scopename': 'gea-shopfloor-cmm', + 'results': [ + {'name': 'Alpha', 'action': 'installed', + 'selfhealed': True}, + {'name': 'Beta', 'action': 'failed', + 'exitcode': 1, 'message': 'boom'}]}, + headers={'X-API-Key': secret}) + reportid = post.get_json()['data']['reportid'] + detail = client.get(f'/api/geenforce/reports/{reportid}', + headers=auth_headers).get_json()['data'] + results = {r['entryname']: r for r in detail['results']} + assert results['Alpha']['selfhealed'] is True + assert results['Beta']['action'] == 'failed' + assert results['Beta']['message'] == 'boom' + + +def test_report_requires_report_scope(client, db, app, auth_headers): + _seed_and_publish(app) + # A fetch-only token cannot report. + secret = _token(client, auth_headers, ['geenforce.fetch']) + resp = client.post('/api/geenforce/report', + json={'hostname': 'WJCMM06'}, + headers={'X-API-Key': secret}) + assert resp.status_code == 401 + + +def test_report_unauthenticated_rejected(client, db, app): + resp = client.post('/api/geenforce/report', json={'hostname': 'WJCMM07'}) + assert resp.status_code == 401 + + +def test_report_missing_hostname_rejected(client, db, app, auth_headers): + secret = _token(client, auth_headers, ['geenforce.report']) + resp = client.post('/api/geenforce/report', json={'scopename': 'x'}, + headers={'X-API-Key': secret}) + assert resp.status_code == 400 diff --git a/tests/test_plugins/test_installed_apps.py b/tests/test_plugins/test_installed_apps.py new file mode 100644 index 00000000..094a1de7 --- /dev/null +++ b/tests/test_plugins/test_installed_apps.py @@ -0,0 +1,57 @@ +"""Installed-applications detail endpoint serializes correctly. + +Regression guard: GET /api/applications/machines/ serializes each +ComputerInstalledApp via its to_dict(). A missing to_dict raised a 500 that the +PCDetail page swallowed (v-if on a non-empty list), so real PCs silently showed +no installed software. This exercises the with-data path that the empty-list +case short-circuits past. +""" + +from shopdb.extensions import db as _db +from shopdb.core.models import Asset, AssetType, Application + +from plugins.computers.models import Computer, ComputerInstalledApp + + +def _seed_pc_with_app(client_db): + atype = AssetType.query.filter_by(assettype='computer').first() + if not atype: + atype = AssetType(assettype='computer') + _db.session.add(atype) + _db.session.flush() + asset = Asset(assetnumber='PC-APPTEST', assettypeid=atype.assettypeid) + _db.session.add(asset) + _db.session.flush() + comp = Computer(assetid=asset.assetid, hostname='PC-APPTEST') + _db.session.add(comp) + _db.session.flush() + app = Application(appname='Test App') + _db.session.add(app) + _db.session.flush() + link = ComputerInstalledApp(computerid=comp.computerid, appid=app.appid, + installedversion='1.2.3') + _db.session.add(link) + _db.session.commit() + return comp + + +def test_installed_apps_endpoint_returns_data(client, db, auth_headers): + comp = _seed_pc_with_app(db) + resp = client.get(f'/api/applications/machines/{comp.computerid}', + headers=auth_headers) + assert resp.status_code == 200 + rows = resp.get_json()['data'] + assert len(rows) == 1 + row = rows[0] + assert row['appname'] == 'Test App' + assert row['installedversion'] == '1.2.3' + assert row['computerid'] == comp.computerid + + +def test_installed_app_to_dict_prefers_curated_version(db): + comp = _seed_pc_with_app(db) + link = ComputerInstalledApp.query.filter_by(computerid=comp.computerid).first() + data = link.to_dict() + assert data['appname'] == 'Test App' + assert data['installedversion'] == '1.2.3' + assert data['isactive'] is True diff --git a/tests/test_plugins/test_measuringtools.py b/tests/test_plugins/test_measuringtools.py new file mode 100644 index 00000000..1143a57e --- /dev/null +++ b/tests/test_plugins/test_measuringtools.py @@ -0,0 +1,475 @@ +"""Tests for the measuringtools plugin. + +The plugin ships default-disabled, so the shared session app does not register +its blueprint. This module builds its own app, registers the blueprint, runs the +plugin's on_install seeding, and restores the process-wide plugin_manager +singleton afterwards (create_app repoints it) so other test modules are +unaffected - the same snapshot/restore dance used by test_plugin_migrations. + +Coverage: derived calibration status (all four buckets + the 30-day boundary), +type CRUD with the in-use delete guard, tool create/update in one merged +payload, and the calibration report shape. +""" + +from datetime import date, timedelta + +import pytest +from werkzeug.security import generate_password_hash + +from shopdb import create_app +from shopdb.extensions import db as _db +from shopdb.plugins import plugin_manager +from plugins.measuringtools.models import ( + derive_status, DUESOON_WINDOW_DAYS, STATUS_COLORS) + + +# ============================================================================= +# Pure unit tests: derived status (no app needed) +# ============================================================================= + +def test_derive_status_unknown_when_no_date(): + assert derive_status(None) == 'unknown' + + +def test_derive_status_overdue_in_past(): + assert derive_status(date.today() - timedelta(days=1)) == 'overdue' + + +def test_derive_status_current_far_future(): + assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS + 1)) == 'current' + + +def test_derive_status_duesoon_within_window(): + assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS - 1)) == 'duesoon' + + +def test_derive_status_duesoon_on_boundary(): + """Exactly DUESOON_WINDOW_DAYS out is still 'due soon' (inclusive).""" + assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS)) == 'duesoon' + + +def test_derive_status_duesoon_today(): + """Due today is not yet overdue - it falls in the due-soon window.""" + assert derive_status(date.today()) == 'duesoon' + + +# ============================================================================= +# API tests (self-contained app with the blueprint registered) +# ============================================================================= + +@pytest.fixture(scope='module') +def mt_app(): + """A testing app with the measuringtools blueprint registered + seeded.""" + saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry, + plugin_manager.loader, plugin_manager.migration_manager, + plugin_manager._registered_prefixes) + application = create_app('testing') + + from plugins.measuringtools.plugin import MeasuringToolsPlugin + plugin = MeasuringToolsPlugin() + pm = application.extensions['plugin_manager'] + if plugin.meta.api_prefix not in pm._registered_prefixes: + pm._register_plugin_components(plugin) + + with application.app_context(): + _db.create_all() + _seed(application, plugin) + yield application + _db.session.remove() + _db.drop_all() + + (plugin_manager._app, plugin_manager._db, plugin_manager.registry, + plugin_manager.loader, plugin_manager.migration_manager, + plugin_manager._registered_prefixes) = saved + + +def _seed(application, plugin): + """Seed permissions, an admin, a status, and the plugin's own reference data.""" + from shopdb.core.models import Permission, User, Role, AssetStatus + Permission.seed() + role = Role(rolename='admin', description='Administrator') + _db.session.add(role) + _db.session.add(AssetStatus(status='In Use', description='In use')) + _db.session.flush() + user = User(username='testadmin', email='admin@test.local', + passwordhash=generate_password_hash('testpass')) + user.roles.append(role) + _db.session.add(user) + _db.session.commit() + # Seeds AssetType 'measuring_tool' + the starter tool types. + plugin.on_install(application) + + +@pytest.fixture +def client(mt_app): + return mt_app.test_client() + + +@pytest.fixture +def auth_headers(client): + response = client.post('/api/auth/login', + json={'username': 'testadmin', 'password': 'testpass'}) + assert response.status_code == 200, response.get_json() + token = response.get_json()['data']['access_token'] + return {'Authorization': f'Bearer {token}'} + + +def _status_id(client): + return 1 # only status seeded + + +# -- Type CRUD + in-use guard ------------------------------------------------- + +def test_starter_types_seeded(client): + response = client.get('/api/measuringtools/types') + assert response.status_code == 200 + names = {t['name'] for t in response.get_json()['data']} + assert {'Caliper', 'Micrometer', 'Thread Gage', 'Other'} <= names + + +def test_type_create_requires_auth(client): + response = client.post('/api/measuringtools/types', json={'name': 'Nope'}) + assert response.status_code == 401 + + +def test_type_crud_lifecycle(client, auth_headers): + created = client.post('/api/measuringtools/types', headers=auth_headers, + json={'name': 'Pin Gage', 'description': 'Pin gage set', + 'color': '#123456'}) + assert created.status_code == 201, created.get_json() + type_id = created.get_json()['data']['measuringtooltypeid'] + + updated = client.put(f'/api/measuringtools/types/{type_id}', headers=auth_headers, + json={'description': 'Updated'}) + assert updated.status_code == 200 + assert updated.get_json()['data']['description'] == 'Updated' + + dup = client.post('/api/measuringtools/types', headers=auth_headers, + json={'name': 'Pin Gage'}) + assert dup.status_code == 409 + + deleted = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers) + assert deleted.status_code == 200 + + +def test_type_delete_blocked_when_in_use(client, auth_headers): + type_created = client.post('/api/measuringtools/types', headers=auth_headers, + json={'name': 'Depth Gage'}) + type_id = type_created.get_json()['data']['measuringtooltypeid'] + tool_created = client.post('/api/measuringtools', headers=auth_headers, + json={'assetnumber': 'MT-INUSE-1', + 'measuringtooltypeid': type_id, + 'statusid': _status_id(client)}) + assert tool_created.status_code == 201, tool_created.get_json() + + blocked = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers) + assert blocked.status_code == 409 + assert 'still use this type' in blocked.get_json()['data']['error']['message'] + + +# -- Tool create/update merged payload ---------------------------------------- + +def test_tool_create_and_get_merged(client, auth_headers): + caliper = client.get('/api/measuringtools/types').get_json()['data'] + caliper_id = next(t['measuringtooltypeid'] for t in caliper if t['name'] == 'Caliper') + + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-001', + 'name': 'Bench caliper', + 'serialnumber': 'SN-CAL-9', + 'gaugelabreference': 'GL-42', + 'measuringtooltypeid': caliper_id, + 'statusid': _status_id(client), + 'calibrationintervaldays': 365, + 'lastcalibrationdate': '2026-01-01', + 'nextcalibrationdate': '2027-01-01', + 'calibrationprovider': 'Metro Cal Lab', + }) + assert created.status_code == 201, created.get_json() + payload = created.get_json()['data'] + # Asset core fields at the top level, extension nested. + assert payload['assetnumber'] == 'MT-001' + assert payload['serialnumber'] == 'SN-CAL-9' + assert payload['gaugelabreference'] == 'GL-42' + ext = payload['measuringtool'] + assert ext['measuringtooltypename'] == 'Caliper' + assert ext['calibrationprovider'] == 'Metro Cal Lab' + assert ext['calibrationstatus'] == 'current' + tool_id = ext['measuringtoolid'] + + fetched = client.get(f'/api/measuringtools/{tool_id}') + assert fetched.status_code == 200 + assert fetched.get_json()['data']['measuringtool']['measuringtoolid'] == tool_id + + +def test_tool_payload_carries_location_code_and_name(mt_app, client, auth_headers): + """A tool at an operation location exposes locationcode (leading token) + and locationname so a printed label can encode the inspection operation.""" + with mt_app.app_context(): + from shopdb.core.models import Location + location = Location(locationname='0615 Blisk Inspection') + _db.session.add(location) + _db.session.commit() + location_id = location.locationid + + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-LOC-1', 'statusid': _status_id(client), + 'locationid': location_id, + }) + assert created.status_code == 201, created.get_json() + payload = created.get_json()['data'] + assert payload['locationname'] == '0615 Blisk Inspection' + assert payload['locationcode'] == '0615' + + +def test_tool_payload_location_code_none_when_unplaced(client, auth_headers): + """A tool with no location degrades gracefully: locationcode is None.""" + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-NOLOC-1', 'statusid': _status_id(client), + }) + assert created.status_code == 201, created.get_json() + assert created.get_json()['data']['locationcode'] is None + + +def test_tool_update_merged_payload(client, auth_headers): + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-UPD-1', 'statusid': _status_id(client), + 'nextcalibrationdate': '2030-01-01', + }) + tool_id = created.get_json()['data']['measuringtool']['measuringtoolid'] + + updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers, json={ + 'name': 'Renamed tool', # asset core field + 'calibrationprovider': 'In-house', # extension field + 'nextcalibrationdate': str(date.today() - timedelta(days=5)), # -> overdue + }) + assert updated.status_code == 200, updated.get_json() + data = updated.get_json()['data'] + assert data['name'] == 'Renamed tool' + assert data['measuringtool']['calibrationprovider'] == 'In-house' + assert data['measuringtool']['calibrationstatus'] == 'overdue' + + +def test_tool_create_duplicate_assetnumber_conflicts(client, auth_headers): + body = {'assetnumber': 'MT-DUP', 'statusid': _status_id(client)} + first = client.post('/api/measuringtools', headers=auth_headers, json=body) + assert first.status_code == 201 + second = client.post('/api/measuringtools', headers=auth_headers, json=body) + assert second.status_code == 409 + + +def test_list_filter_by_calibrationstatus(client, auth_headers): + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-OVERDUE-1', 'statusid': _status_id(client), + 'nextcalibrationdate': str(date.today() - timedelta(days=10)), + }) + response = client.get('/api/measuringtools?calibrationstatus=overdue') + assert response.status_code == 200 + rows = response.get_json()['data'] + assert rows, 'expected at least one overdue tool' + assert all(r['measuringtool']['calibrationstatus'] == 'overdue' for r in rows) + + +# -- Maintenance reference identifier ----------------------------------------- + +def test_maintenancereference_roundtrip(client, auth_headers): + """Maintenance reference persists on create and update.""" + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-MNT-1', 'statusid': _status_id(client), + 'maintenancereference': 'MNT-9', + }) + assert created.status_code == 201, created.get_json() + assert created.get_json()['data']['maintenancereference'] == 'MNT-9' + tool_id = created.get_json()['data']['measuringtool']['measuringtoolid'] + + updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers, + json={'maintenancereference': 'MNT-10'}) + assert updated.status_code == 200 + assert updated.get_json()['data']['maintenancereference'] == 'MNT-10' + + +# -- Global search ------------------------------------------------------------ + +def _mt_search_hits(client, auth_headers, term): + resp = client.get(f'/api/search?q={term}', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + return [r for r in resp.get_json()['data']['results'] + if r.get('type') == 'measuring_tool'] + + +def test_search_finds_tool_by_assetnumber(mt_app, client, auth_headers, monkeypatch): + """A measuring tool appears in global search by asset number, routed right.""" + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-SEARCH-AN', 'statusid': _status_id(client)}) + pm = mt_app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + hits = _mt_search_hits(client, auth_headers, 'MT-SEARCH-AN') + assert hits + assert hits[0]['url'].startswith('/measuringtools/') + + +def test_search_finds_tool_by_gaugelabreference(mt_app, client, auth_headers, monkeypatch): + """A gage-tag lookup by gaugelabreference finds the tool.""" + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-SEARCH-GL', 'statusid': _status_id(client), + 'gaugelabreference': 'GLREF-778'}) + pm = mt_app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + hits = _mt_search_hits(client, auth_headers, 'GLREF-778') + assert hits + assert hits[0]['url'].startswith('/measuringtools/') + + +def test_search_excludes_tools_when_domain_disabled(mt_app, client, auth_headers, monkeypatch): + """search_measuring_tool_enabled=false hides measuring tools from search.""" + from shopdb.core.models import Setting + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-SEARCH-OFF', 'statusid': _status_id(client), + 'gaugelabreference': 'GLREF-OFF-1'}) + pm = mt_app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + Setting.set('search_measuring_tool_enabled', False, valuetype='boolean', + category='search') + try: + assert _mt_search_hits(client, auth_headers, 'MT-SEARCH-OFF') == [] + assert _mt_search_hits(client, auth_headers, 'GLREF-OFF-1') == [] + finally: + Setting.set('search_measuring_tool_enabled', True, valuetype='boolean', + category='search') + + +# -- Report shape ------------------------------------------------------------- + +def test_calibration_report_shape(client, auth_headers): + response = client.get('/api/measuringtools/report/calibration') + assert response.status_code == 200 + data = response.get_json()['data'] + assert set(data['counts']) == {'overdue', 'duesoon', 'current', 'unknown'} + assert set(data['buckets']) == {'overdue', 'duesoon', 'current', 'unknown'} + # Buckets and counts agree. + for key, rows in data['buckets'].items(): + assert data['counts'][key] == len(rows) + assert 'statuscolors' in data + + +# -- Core integration: asset serialization ------------------------------------ + +def _caliper_id(client): + types = client.get('/api/measuringtools/types').get_json()['data'] + return next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper') + + +def test_asset_todict_carries_measuringtool_typedata_and_pluginid(mt_app, client, auth_headers): + """Asset.to_dict resolves the measuringtool extension (typedata + pluginid).""" + from shopdb.core.models import Asset + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-TD-1', 'statusid': _status_id(client), + 'measuringtooltypeid': _caliper_id(client), + }) + assert created.status_code == 201, created.get_json() + tool_id = created.get_json()['data']['measuringtool']['measuringtoolid'] + assetid = created.get_json()['data']['assetid'] + + with mt_app.app_context(): + asset = _db.session.get(Asset, assetid) + result = asset.to_dict(include_type_data=True) + assert result['pluginid'] == tool_id + assert result['typedata']['measuringtooltypename'] == 'Caliper' + assert result['typedata']['measuringtoolid'] == tool_id + + +# -- Core integration: shop-floor map ----------------------------------------- + +def test_map_lists_measuringtool_subtypes(client, auth_headers): + """The map filter dropdown carries a Measuring Tool subtype list with color.""" + response = client.get('/api/assets/map') + assert response.status_code == 200, response.get_json() + subtypes = response.get_json()['data']['filters']['subtypes'] + assert 'Measuring Tool' in subtypes + names = {s['name'] for s in subtypes['Measuring Tool']} + assert 'Caliper' in names + assert all('color' in s for s in subtypes['Measuring Tool']) + + +def test_map_honors_measuringtool_subtype_filter(client, auth_headers): + """?assettype=measuring_tool&subtype= returns only that subtype's tools.""" + types = client.get('/api/measuringtools/types').get_json()['data'] + caliper_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper') + micrometer_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Micrometer') + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-MAP-CAL', 'statusid': _status_id(client), + 'measuringtooltypeid': caliper_id, 'mapx': 10, 'mapy': 20}) + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-MAP-MIC', 'statusid': _status_id(client), + 'measuringtooltypeid': micrometer_id, 'mapx': 30, 'mapy': 40}) + + response = client.get( + f'/api/assets/map?assettype=measuring_tool&subtype={caliper_id}') + assert response.status_code == 200, response.get_json() + numbers = {a['assetnumber'] for a in response.get_json()['data']['assets']} + assert 'MT-MAP-CAL' in numbers + assert 'MT-MAP-MIC' not in numbers + + +def test_map_item_carries_measuringtool_typedata(client, auth_headers): + """A mapped tool's item carries the extension typedata for marker coloring.""" + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-MAP-TD', 'statusid': _status_id(client), + 'measuringtooltypeid': _caliper_id(client), 'mapx': 55, 'mapy': 66}) + response = client.get('/api/assets/map?assettype=measuring_tool') + item = next(a for a in response.get_json()['data']['assets'] + if a['assetnumber'] == 'MT-MAP-TD') + assert item['typedata']['measuringtooltypename'] == 'Caliper' + + +# -- Core integration: dashboard ---------------------------------------------- + +def test_dashboard_counts_include_measuringtools(client, auth_headers): + """Dashboard total and counts include active measuring tools.""" + before = client.get('/api/dashboard').get_json()['data'] + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-DASH-1', 'statusid': _status_id(client)}) + after = client.get('/api/dashboard').get_json()['data'] + assert after['counts']['measuringtools'] == before['counts']['measuringtools'] + 1 + assert after['totalmeasuringtool'] == before['totalmeasuringtool'] + 1 + assert after['counts']['total'] == before['counts']['total'] + 1 + + +# -- Map overlay endpoint (ADR-010) ------------------------------------------- + +def test_map_overlay_shape_and_derivation(client, auth_headers): + """map-overlay returns per-asset derived calibration status + color.""" + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-OVL-1', 'statusid': _status_id(client), + 'nextcalibrationdate': str(date.today() - timedelta(days=3))}) # overdue + assetid = created.get_json()['data']['assetid'] + + response = client.get('/api/measuringtools/map-overlay') + assert response.status_code == 200, response.get_json() + rows = response.get_json()['data'] + row = next(r for r in rows if r['assetid'] == assetid) + assert set(row) == {'assetid', 'calibrationstatus', 'statuscolor'} + assert row['calibrationstatus'] == 'overdue' + assert row['statuscolor'] == STATUS_COLORS['overdue'] + + +def test_map_overlay_excludes_inactive(client, auth_headers): + """A soft-deleted tool drops out of the overlay.""" + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-OVL-DEL', 'statusid': _status_id(client)}) + tool_id = created.get_json()['data']['measuringtool']['measuringtoolid'] + assetid = created.get_json()['data']['assetid'] + client.delete(f'/api/measuringtools/{tool_id}', headers=auth_headers) + + rows = client.get('/api/measuringtools/map-overlay').get_json()['data'] + assert all(r['assetid'] != assetid for r in rows) + + +# -- Presentation route token (ADR-010) --------------------------------------- + +def test_asset_presentation_route_token(): + """Presentation route links through the by-asset resolver (only {assetid}).""" + from plugins.measuringtools.plugin import MeasuringToolsPlugin + entries = MeasuringToolsPlugin().get_asset_presentation() + entry = next(e for e in entries if e['assettype'] == 'measuring_tool') + assert entry['route'] == '/measuringtools/by-asset/{assetid}' diff --git a/tests/test_plugins/test_model_image_in_detail.py b/tests/test_plugins/test_model_image_in_detail.py new file mode 100644 index 00000000..4b49f836 --- /dev/null +++ b/tests/test_plugins/test_model_image_in_detail.py @@ -0,0 +1,100 @@ +"""Vendor-model photo surfaces in asset-extension detail payloads. + +Machines, computers, and printers link a vendor Model via modelnumberid; when +that Model carries an imageurl, the extension dict in the GET detail payload +must expose it as 'imageurl' (the field name machines established, consumed by +the detail-page heroes and the machine badge). When no model is linked, the +field must be absent so the frontend v-if renders nothing. + +Network devices and measuring tools have no model link, so they are out of +scope here by design. +""" + +from shopdb.extensions import db as _db +from shopdb.core.models import Asset, AssetType, Model + +from plugins.machines.models import Machine +from plugins.computers.models import Computer +from plugins.printers.models import Printer + +IMAGE_URL = '/api/models/images/model-1.png' + + +def _seed_asset(assetnumber, assettype): + atype = AssetType.query.filter_by(assettype=assettype).first() + if not atype: + atype = AssetType(assettype=assettype) + _db.session.add(atype) + _db.session.flush() + asset = Asset(assetnumber=assetnumber, assettypeid=atype.assettypeid) + _db.session.add(asset) + _db.session.flush() + return asset + + +def _seed_model(imageurl=IMAGE_URL): + model = Model(modelnumber='IMG-MODEL-1', imageurl=imageurl) + _db.session.add(model) + _db.session.flush() + return model + + +def test_machine_detail_carries_model_imageurl(client, db): + asset = _seed_asset('2001', 'machine') + model = _seed_model() + machine = Machine(assetid=asset.assetid, modelnumberid=model.modelnumberid) + _db.session.add(machine) + _db.session.commit() + + resp = client.get(f'/api/machines/{machine.machineid}') + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['machine']['imageurl'] == IMAGE_URL + + +def test_computer_detail_carries_model_imageurl(client, db): + asset = _seed_asset('PC-2001', 'computer') + model = _seed_model() + computer = Computer(assetid=asset.assetid, modelnumberid=model.modelnumberid) + _db.session.add(computer) + _db.session.commit() + + resp = client.get(f'/api/computers/{computer.computerid}') + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['computer']['imageurl'] == IMAGE_URL + + +def test_printer_detail_carries_model_imageurl(client, db): + asset = _seed_asset('PR-2001', 'printer') + model = _seed_model() + printer = Printer(assetid=asset.assetid, modelnumberid=model.modelnumberid) + _db.session.add(printer) + _db.session.commit() + + resp = client.get(f'/api/printers/{printer.printerid}') + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['printer']['imageurl'] == IMAGE_URL + + +def test_detail_omits_imageurl_without_model(client, db): + """No model linked -> no imageurl key (frontend v-if shows nothing).""" + asset = _seed_asset('PR-2002', 'printer') + printer = Printer(assetid=asset.assetid) + _db.session.add(printer) + _db.session.commit() + + resp = client.get(f'/api/printers/{printer.printerid}') + assert resp.status_code == 200, resp.get_json() + assert 'imageurl' not in resp.get_json()['data']['printer'] + + +def test_detail_omits_imageurl_when_model_has_no_image(client, db): + """Model linked but no photo -> no imageurl key.""" + asset = _seed_asset('PC-2002', 'computer') + model = _seed_model(imageurl=None) + computer = Computer(assetid=asset.assetid, modelnumberid=model.modelnumberid) + _db.session.add(computer) + _db.session.commit() + + resp = client.get(f'/api/computers/{computer.computerid}') + assert resp.status_code == 200, resp.get_json() + assert 'imageurl' not in resp.get_json()['data']['computer'] diff --git a/tests/test_plugins/test_modelsupplies.py b/tests/test_plugins/test_modelsupplies.py new file mode 100644 index 00000000..f83d6e76 --- /dev/null +++ b/tests/test_plugins/test_modelsupplies.py @@ -0,0 +1,121 @@ +"""Tests for the model-supplies (toner part-number) management API.""" + +import pytest + + +@pytest.fixture +def model(db): + """A vendor + printer model to attach supplies to.""" + from shopdb.core.models import Vendor, Model + + vendor = Vendor(vendor='TestVendor') + db.session.add(vendor) + db.session.flush() + + model = Model(modelnumber='TestModel C999', vendorid=vendor.vendorid) + db.session.add(model) + db.session.commit() + return model + + +def test_supplies_meta_lists_allowed_values(client, db): + response = client.get('/api/printers/supplies/meta') + assert response.status_code == 200 + data = response.get_json()['data'] + assert 'toner' in data['supplytypes'] + assert 'black' in data['colors'] + assert 'metered' in data['capacitytiers'] + + +def test_create_and_list_model_supply(client, model, auth_headers): + create = client.post( + f'/api/printers/models/{model.modelnumberid}/supplies', + json={ + 'supplytype': 'toner', 'color': 'black', 'capacitytier': 'standard', + 'partnumber': 'W2020A', 'marketingname': '414A Black', 'pageyield': 2400, + }, + headers=auth_headers, + ) + assert create.status_code == 201 + + listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies') + assert listing.status_code == 200 + supplies = listing.get_json()['data']['supplies'] + assert len(supplies) == 1 + assert supplies[0]['partnumber'] == 'W2020A' + + +def test_duplicate_partnumber_rejected(client, model, auth_headers): + payload = {'partnumber': 'W2020A', 'color': 'black'} + client.post(f'/api/printers/models/{model.modelnumberid}/supplies', + json=payload, headers=auth_headers) + second = client.post(f'/api/printers/models/{model.modelnumberid}/supplies', + json=payload, headers=auth_headers) + assert second.status_code == 409 + + +def test_invalid_enum_rejected(client, model, auth_headers): + response = client.post( + f'/api/printers/models/{model.modelnumberid}/supplies', + json={'partnumber': 'X1', 'color': 'purple'}, + headers=auth_headers, + ) + assert response.status_code == 400 + + +def test_create_requires_auth(client, model): + response = client.post( + f'/api/printers/models/{model.modelnumberid}/supplies', + json={'partnumber': 'X1'}, + ) + assert response.status_code == 401 + + +def test_update_and_delete_supply(client, model, auth_headers): + created = client.post( + f'/api/printers/models/{model.modelnumberid}/supplies', + json={'partnumber': 'W2020A', 'color': 'black', 'capacitytier': 'standard'}, + headers=auth_headers, + ).get_json()['data'] + supplyid = created['modelsupplyid'] + + updated = client.put( + f'/api/printers/supplies/{supplyid}', + json={'capacitytier': 'high', 'marketingname': '414X Black'}, + headers=auth_headers, + ) + assert updated.status_code == 200 + assert updated.get_json()['data']['capacitytier'] == 'high' + + deleted = client.delete(f'/api/printers/supplies/{supplyid}', headers=auth_headers) + assert deleted.status_code == 200 + + listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies') + assert listing.get_json()['data']['supplies'] == [] + + +def test_listmodels_reports_supplycount(client, model, auth_headers): + client.post(f'/api/printers/models/{model.modelnumberid}/supplies', + json={'partnumber': 'W2020A', 'color': 'black'}, headers=auth_headers) + + response = client.get('/api/printers/models', query_string={'search': 'C999'}) + assert response.status_code == 200 + rows = response.get_json()['data'] + match = next(r for r in rows if r['modelnumberid'] == model.modelnumberid) + assert match['supplycount'] == 1 + + +def test_seed_supplies_corrected_data(app, db): + """The seed loads corrected part numbers (C405 colors, no B405 waste).""" + with app.app_context(): + from plugins.printers.services import seedsupplies, lookupsupplies + from shopdb.core.models import Model + + seedsupplies() + + c405 = Model.query.filter(Model.modelnumber.ilike('%C405%')).first() + yellow = lookupsupplies(c405.modelnumberid, 'yellow', 'toner') + assert any(s['partnumber'] == '106R03501' for s in yellow) + + b405 = Model.query.filter(Model.modelnumber.ilike('%B405%')).first() + assert lookupsupplies(b405.modelnumberid, 'none', 'waste') == [] diff --git a/tests/test_plugins/test_network_ipaddress.py b/tests/test_plugins/test_network_ipaddress.py new file mode 100644 index 00000000..1fd9fe80 --- /dev/null +++ b/tests/test_plugins/test_network_ipaddress.py @@ -0,0 +1,68 @@ +"""Network device create/update attaches a primary IP Communication. + +Classic network devices carry their IP in the communications table, and the +legacy import needs an HTTP path to set it. The network create/update routes +accept an `ipaddress` and materialize a primary Communication (mirroring the +printer route); GET surfaces it back. +""" + +import pytest + + +@pytest.fixture +def network_setup(db): + from shopdb.core.models import AssetType, CommunicationType + db.session.add(AssetType(assettype='network_device', pluginname='network', + tablename='networkdevices', description='Network devices')) + db.session.add(CommunicationType(comtype='IP', description='IP')) + db.session.commit() + + +def test_create_network_device_with_ipaddress(client, db, auth_headers, network_setup): + """POST /api/network with an ipaddress creates a primary Communication and + echoes the IP back.""" + from shopdb.core.models import Communication + resp = client.post('/api/network', json={ + 'assetnumber': 'SW-CORE-01', 'name': 'Core switch', + 'hostname': 'sw-core-01', 'ipaddress': '10.129.22.101', + }, headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + data = resp.get_json()['data'] + assert data['ipaddress'] == '10.129.22.101' + + assetid = data['assetid'] + comm = Communication.query.filter_by(assetid=assetid, isprimary=True).first() + assert comm is not None and comm.ipaddress == '10.129.22.101' + + # GET surfaces it too. + got = client.get(f"/api/network/{data['networkdevice']['networkdeviceid']}", + headers=auth_headers) + assert got.get_json()['data']['ipaddress'] == '10.129.22.101' + + +def test_update_network_device_ipaddress_upserts(client, db, auth_headers, network_setup): + """PUT changes the IP in place without creating a second Communication.""" + from shopdb.core.models import Communication + created = client.post('/api/network', json={ + 'assetnumber': 'AP-01', 'name': 'AP', 'ipaddress': '10.0.0.5', + }, headers=auth_headers).get_json()['data'] + devid = created['networkdevice']['networkdeviceid'] + + updated = client.put(f'/api/network/{devid}', json={'ipaddress': '10.0.0.9'}, + headers=auth_headers) + assert updated.status_code == 200, updated.get_json() + assert updated.get_json()['data']['ipaddress'] == '10.0.0.9' + + comms = Communication.query.filter_by(assetid=created['assetid']).all() + assert len(comms) == 1 and comms[0].ipaddress == '10.0.0.9' + + +def test_seed_reference_data_creates_ip_commtype(app, db): + """The reference-data seed now creates the IP communication type the asset + import depends on.""" + from shopdb.core.models import CommunicationType + runner = app.test_cli_runner() + assert runner.invoke(args=['seed', 'reference-data']).exit_code in (0, None) + with app.app_context(): + assert CommunicationType.query.filter_by(comtype='IP').first() is not None + assert CommunicationType.query.count() >= 8 diff --git a/tests/test_plugins/test_pc_default_printer.py b/tests/test_plugins/test_pc_default_printer.py new file mode 100644 index 00000000..448c8409 --- /dev/null +++ b/tests/test_plugins/test_pc_default_printer.py @@ -0,0 +1,69 @@ +"""Tests for the per-PC default-printer endpoint (/api/printers/pc-default). + +Parity with classic apipcdefaultprinter.asp: the signed printer-installer EXE +preselects a PC's default printer by machine (asset) number. The link is a +`defaultprinter` asset relationship (PC asset -> printer asset). +""" + +import pytest + +from shopdb.core.models import Asset, AssetType, AssetRelationship, RelationshipType +from plugins.printers.models import Printer + + +@pytest.fixture +def scene(db): + """A PC asset, a printer asset, and the defaultprinter relationship type.""" + pc_type = AssetType(assettype='computer', pluginname='computers', tablename='computers') + pr_type = AssetType(assettype='printer', pluginname='printers', tablename='printers') + dp_type = RelationshipType(relationshiptype='defaultprinter', description='PC to default printer') + db.session.add_all([pc_type, pr_type, dp_type]) + db.session.flush() + + pc = Asset(assetnumber='3210', name='CSF04 PC', assettypeid=pc_type.assettypeid, isactive=True) + pr_asset = Asset(assetnumber='PRN-01', name='Materials HP', assettypeid=pr_type.assettypeid, isactive=True) + db.session.add_all([pc, pr_asset]) + db.session.flush() + + printer = Printer(assetid=pr_asset.assetid, windowsname='HP-CSF04-Materials') + db.session.add(printer) + db.session.commit() + + return {'pc': pc, 'printer_asset': pr_asset, 'printer': printer, 'dp_type': dp_type} + + +def _link_default(db, pc, printer_asset, dp_type): + db.session.add(AssetRelationship( + sourceassetid=pc.assetid, + targetassetid=printer_asset.assetid, + relationshiptypeid=dp_type.relationshiptypeid, + )) + db.session.commit() + + +def test_returns_default_printer_for_pc(client, db, scene): + _link_default(db, scene['pc'], scene['printer_asset'], scene['dp_type']) + + resp = client.get('/api/printers/pc-default?machine=3210') + assert resp.status_code == 200 + data = resp.get_json()['data'] + assert data['printerid'] == scene['printer'].printerid + assert data['windowsname'] == 'HP-CSF04-Materials' + + +def test_pc_without_default_returns_empty(client, db, scene): + resp = client.get('/api/printers/pc-default?machine=3210') + assert resp.status_code == 200 + assert resp.get_json()['data'] == {} + + +def test_unknown_machine_returns_empty(client, db, scene): + resp = client.get('/api/printers/pc-default?machine=NOPE') + assert resp.status_code == 200 + assert resp.get_json()['data'] == {} + + +def test_missing_machine_param_returns_empty(client, db, scene): + resp = client.get('/api/printers/pc-default') + assert resp.status_code == 200 + assert resp.get_json()['data'] == {} diff --git a/tests/test_plugins/test_printer_install_list.py b/tests/test_plugins/test_printer_install_list.py new file mode 100644 index 00000000..1a61a6e0 --- /dev/null +++ b/tests/test_plugins/test_printer_install_list.py @@ -0,0 +1,48 @@ +"""Tests for the printer installer endpoint (/api/printers/install-list). + +The signed printer installer EXE pulls network-printer data + floor-map +positions from here (shopfloor 2.0 cannot run unsigned .bat maps). +""" + +import pytest + + +@pytest.fixture +def printer_assettype(db): + from shopdb.core.models import AssetType + at = AssetType(assettype='printer', pluginname='printers', + tablename='printers', description='Printers') + db.session.add(at) + db.session.commit() + return at + + +def test_install_list_returns_network_printer_with_map(client, db, auth_headers, + printer_assettype): + """A network printer appears with flat installer fields incl map position.""" + created = client.post('/api/printers', json={ + 'assetnumber': 'CSF01-Materials-HP', + 'hostname': 'wjprn01', + 'installpath': r'\\srv\drivers\hp', + 'mapx': 120, 'mapy': 240, + }, headers=auth_headers) + assert created.status_code == 201, created.get_json() + + resp = client.get('/api/printers/install-list', headers=auth_headers) + assert resp.status_code == 200 + rows = resp.get_json()['data'] + row = next((r for r in rows if r['machinenumber'] == 'CSF01-Materials-HP'), None) + assert row is not None + assert row['hostname'] == 'wjprn01' + assert row['installpath'] == r'\\srv\drivers\hp' + assert row['mapx'] == 120 and row['mapy'] == 240 + + +def test_install_list_excludes_usb_only_printer(client, db, auth_headers, + printer_assettype): + """A printer with no hostname and no network IP is excluded.""" + client.post('/api/printers', json={'assetnumber': 'USB-LABELER'}, + headers=auth_headers) + resp = client.get('/api/printers/install-list', headers=auth_headers) + machinenumbers = [r['machinenumber'] for r in resp.get_json()['data']] + assert 'USB-LABELER' not in machinenumbers diff --git a/tests/test_plugins/test_shopfloor_feed.py b/tests/test_plugins/test_shopfloor_feed.py new file mode 100644 index 00000000..21d8b4aa --- /dev/null +++ b/tests/test_plugins/test_shopfloor_feed.py @@ -0,0 +1,72 @@ +"""Tests for the shopfloor TV feed (/api/notifications/shopfloor). + +Since the notification-type display rework, per-employee fan-out is driven by +the type's splitperemployee column (seeded true for Recognition/Training): +a note naming several comma-joined SSOs yields one card per employee; types +without the flag stay a single card. +""" + +import pytest + +from plugins.notifications.models import Notification, NotificationType + + +def _make_type(db, typename, typecolor, splitperemployee=False): + t = NotificationType(typename=typename, typecolor=typecolor, + splitperemployee=splitperemployee, isactive=True) + db.session.add(t) + db.session.commit() + return t + + +def _make_shopfloor_note(db, ntype, employeesso, employeename): + n = Notification( + notificationtypeid=ntype.notificationtypeid, + notification='msg', + businessunitid=None, + isactive=True, + isshopfloor=True, + employeesso=employeesso, + employeename=employeename, + ) + db.session.add(n) + db.session.commit() + return n + + +@pytest.mark.parametrize('typecolor', ['recognition', 'training']) +def test_multi_employee_split_into_one_card_each(client, db, typecolor): + """A split-flagged note with two SSOs yields two current cards.""" + ntype = _make_type(db, typecolor.capitalize(), typecolor, splitperemployee=True) + _make_shopfloor_note(db, ntype, '111,222', 'Alice, Bob') + + resp = client.get('/api/notifications/shopfloor') + assert resp.status_code == 200 + current = resp.get_json()['data']['current'] + assert len(current) == 2 + assert {c['employeesso'] for c in current} == {'111', '222'} + assert {c['employeename'] for c in current} == {'Alice', 'Bob'} + + +def test_non_split_type_stays_single_card(client, db): + """A type without splitperemployee is not fanned out, even with many SSOs.""" + ntype = _make_type(db, 'Awareness', 'info') + _make_shopfloor_note(db, ntype, '111,222', 'Alice, Bob') + + resp = client.get('/api/notifications/shopfloor') + assert resp.status_code == 200 + current = resp.get_json()['data']['current'] + assert len(current) == 1 + assert current[0]['employeesso'] == '111,222' + + +def test_single_employee_recognition_stays_single_card(client, db): + """One SSO produces one card (no spurious split on a lone employee).""" + ntype = _make_type(db, 'Recognition', 'recognition', splitperemployee=True) + _make_shopfloor_note(db, ntype, '111', 'Alice') + + resp = client.get('/api/notifications/shopfloor') + assert resp.status_code == 200 + current = resp.get_json()['data']['current'] + assert len(current) == 1 + assert current[0]['employeesso'] == '111' diff --git a/tests/test_security_config.py b/tests/test_security_config.py new file mode 100644 index 00000000..0cd1e179 --- /dev/null +++ b/tests/test_security_config.py @@ -0,0 +1,90 @@ +"""Tests pinning production-config validation behavior.""" + +import os +import pytest + +from shopdb.config import ProductionConfig, ConfigError + + +@pytest.fixture +def clean_env(monkeypatch): + """Clear all env vars that ProductionConfig.validate looks at.""" + for key in ('SECRET_KEY', 'JWT_SECRET_KEY', 'DATABASE_URL', 'CORS_ORIGINS'): + monkeypatch.delenv(key, raising=False) + return monkeypatch + + +def test_production_validate_raises_on_missing_secret_key(clean_env): + """Empty SECRET_KEY in production must fail loud at boot.""" + with pytest.raises(ConfigError, match='SECRET_KEY'): + ProductionConfig.validate() + + +def test_production_validate_raises_on_dev_secret_key(clean_env): + """The dev fallback must not be accepted in production.""" + clean_env.setenv('SECRET_KEY', 'dev-secret-key-change-in-production') + with pytest.raises(ConfigError, match='SECRET_KEY'): + ProductionConfig.validate() + + +def test_production_validate_raises_on_missing_jwt_secret(clean_env): + """Empty JWT_SECRET_KEY in production must fail loud at boot.""" + clean_env.setenv('SECRET_KEY', 'a-real-strong-key') + with pytest.raises(ConfigError, match='JWT_SECRET_KEY'): + ProductionConfig.validate() + + +def test_production_validate_raises_on_missing_database_url(clean_env): + """Production must not silently fall back to a localhost MySQL URL.""" + clean_env.setenv('SECRET_KEY', 'a-real-strong-key') + clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key') + with pytest.raises(ConfigError, match='DATABASE_URL'): + ProductionConfig.validate() + + +def test_production_validate_raises_on_wildcard_cors(clean_env): + """CORS wildcard is rejected in production.""" + clean_env.setenv('SECRET_KEY', 'a-real-strong-key') + clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key') + clean_env.setenv('DATABASE_URL', 'mysql+pymysql://u:p@db/shopdb') + clean_env.setenv('CORS_ORIGINS', '*') + with pytest.raises(ConfigError, match='CORS_ORIGINS'): + ProductionConfig.validate() + + +def test_production_validate_raises_on_empty_cors(clean_env): + """Empty CORS allowlist is rejected in production.""" + clean_env.setenv('SECRET_KEY', 'a-real-strong-key') + clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key') + clean_env.setenv('DATABASE_URL', 'mysql+pymysql://u:p@db/shopdb') + with pytest.raises(ConfigError, match='CORS_ORIGINS'): + ProductionConfig.validate() + + +def test_production_validate_passes_with_complete_config(clean_env): + """All required env vars set with non-default values: validate passes.""" + clean_env.setenv('SECRET_KEY', 'a-real-strong-key') + clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key') + clean_env.setenv('DATABASE_URL', 'mysql+pymysql://u:p@db/shopdb') + clean_env.setenv('CORS_ORIGINS', 'https://shopdb.example.com') + ProductionConfig.validate() + + +def test_per_plugin_collector_key_loaded_from_env(monkeypatch): + """COLLECTOR_API_KEY_ is a dynamic env var; create_app must load it. + + from_object only copies class attributes, so per-plugin keys (ADR-006) + would be invisible without the explicit env scan in create_app. + """ + from shopdb import create_app + + monkeypatch.setenv('COLLECTOR_API_KEY_COMPUTERS', 'computers-secret') + app = create_app('testing') + assert app.config.get('COLLECTOR_API_KEY_COMPUTERS') == 'computers-secret' + + +def test_employee_db_password_has_no_default(): + """No safe default for the employee-DB password: unset means empty.""" + if 'EMPLOYEE_DB_PASSWORD' not in os.environ: + from shopdb.config import Config + assert Config.EMPLOYEE_DB_PASSWORD == '' diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 00000000..886f25ab --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,113 @@ +"""Smoke tests pinning the baseline behavior of shopdb-flask. + +These eight tests are the safety net required before any structural +refactor proceeds. +""" + +import pytest + + +def test_app_factory_creates_app(app): + """create_app('testing') returns a Flask app with TESTING=True.""" + assert app is not None + assert app.config['TESTING'] is True + assert 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI'] + + +def test_login_with_valid_credentials_returns_tokens(client, admin_user): + """POST /api/auth/login with valid creds returns access and refresh tokens.""" + response = client.post( + '/api/auth/login', + json={'username': 'testadmin', 'password': 'testpass'}, + ) + assert response.status_code == 200 + payload = response.get_json() + assert 'data' in payload + data = payload['data'] + assert 'access_token' in data + assert 'refresh_token' in data + assert 'user' in data + assert data['user']['username'] == 'testadmin' + + +def test_login_with_invalid_credentials_returns_401(client, admin_user): + """Wrong password returns 401 with the documented error envelope. + + Pins the current shape: error info nested under `data.error` (not at + top level). The error_response docstring claims top-level `error` but + the implementation puts it under `data`. Pinned as-is until that + inconsistency is intentionally addressed. + """ + response = client.post( + '/api/auth/login', + json={'username': 'testadmin', 'password': 'wrongpassword'}, + ) + assert response.status_code == 401 + payload = response.get_json() + assert payload['status'] == 'error' + assert payload['data']['error']['code'] == 'UNAUTHORIZED' + + +def test_login_with_missing_fields_returns_400(client): + """Missing username or password returns 400 validation error.""" + response = client.post('/api/auth/login', json={}) + assert response.status_code == 400 + + +def test_protected_route_requires_authentication(client, admin_user): + """GET /api/users without a JWT returns 401.""" + response = client.get('/api/users') + assert response.status_code == 401 + + +def test_protected_route_works_with_jwt(client, auth_headers): + """GET /api/users with a valid JWT returns 200.""" + response = client.get('/api/users', headers=auth_headers) + assert response.status_code == 200 + + +def test_paginated_response_shape(client, auth_headers): + """A paginated list endpoint returns data plus pagination meta. + + Uses /api/locations because it is a simple platform endpoint that + uses paginated_response. Pagination meta keys follow the naming + convention (lowercase concatenated): page, perpage, total, + totalpages, hasnext, hasprev. + """ + response = client.get('/api/locations', headers=auth_headers) + assert response.status_code == 200 + payload = response.get_json() + assert 'data' in payload + assert isinstance(payload['data'], list) + assert 'meta' in payload + assert 'pagination' in payload['meta'] + pagination = payload['meta']['pagination'] + assert 'page' in pagination + assert 'perpage' in pagination + assert 'total' in pagination + assert 'totalpages' in pagination + + +def test_plugin_loader_discovers_bundled_plugins(app): + """Plugin manager finds the bundled plugins.""" + from shopdb.plugins import plugin_manager + + expected_plugins = { + 'computers', + 'employees', + 'knowledgebase', + 'machines', + 'network', + 'notifications', + 'printers', + 'slides', + 'usb', + } + + with app.app_context(): + loader = plugin_manager.loader + discovered = set(loader.discover_plugins()) + + assert expected_plugins.issubset(discovered), ( + f'Missing bundled plugins: {expected_plugins - discovered}' + ) diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 00000000..829a9090 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,45 @@ +"""WSGI entry point for the application.""" + +import os +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +from shopdb import create_app + +app = create_app(os.environ.get('FLASK_ENV', 'development')) + + +class MountPathMiddleware: + """Serve the whole app (API + SPA) under a URL prefix, e.g. '/ops'. + + Used when the app is deployed as an IIS Application under an existing + site instead of its own site: IIS forwards the full request path + ('/ops/api/...'), so the prefix is moved from PATH_INFO to SCRIPT_NAME + before Flask routes it. Flask then also generates URLs under the prefix. + The frontend must be built with the matching VITE_BASE_PATH ('/ops/'). + """ + + def __init__(self, wsgi_app, mountpath): + self.wsgi_app = wsgi_app + self.mountpath = '/' + mountpath.strip('/') + + def __call__(self, environ, start_response): + path = environ.get('PATH_INFO', '') + if path == self.mountpath or path.startswith(self.mountpath + '/'): + environ['SCRIPT_NAME'] = environ.get('SCRIPT_NAME', '') + self.mountpath + environ['PATH_INFO'] = path[len(self.mountpath):] or '/' + return self.wsgi_app(environ, start_response) + start_response('404 Not Found', [('Content-Type', 'text/plain')]) + return [b'Not Found: the app is mounted at ' + self.mountpath.encode() + b'/'] + + +# MOUNT_PATH (.env or web.config) activates the subpath deployment method. +# Unset/empty = the app owns the server root (its own IIS site; the default). +_mountpath = os.environ.get('MOUNT_PATH', '').strip() +if _mountpath and _mountpath != '/': + app.wsgi_app = MountPathMiddleware(app.wsgi_app, _mountpath) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5001, debug=True)