diff --git a/.env.example b/.env.example index ce2de8f..0fa2371 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,12 @@ ZABBIX_TOKEN= # 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 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 7e9e74e..e1f5190 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -32,6 +32,9 @@ Edit `.env`: | `API_PORT` | No | Default 5001 | | `LOG_LEVEL` | No | Default INFO | | `ZABBIX_URL`, `ZABBIX_TOKEN` | No | Only if printers plugin uses Zabbix | +| `COLLECTOR_API_KEY` | No | Shared key for `/api/collector/*` ingest. Required only if unattended collectors push data. Endpoint fails closed (denies) when unset. | +| `COLLECTOR_API_KEY_` | No | Per-plugin override (e.g. `COLLECTOR_API_KEY_COMPUTERS`), checked before the shared key (ADR-006) | +| `EMPLOYEE_DB_HOST/USER/PASSWORD/NAME` | No | Read-only HR directory for notifications + kiosks. No safe default for the password. | ## Step 2: Bring up the stack diff --git a/docs/PLUGIN-HOOKS.md b/docs/PLUGIN-HOOKS.md index 87e5606..9d14be7 100644 --- a/docs/PLUGIN-HOOKS.md +++ b/docs/PLUGIN-HOOKS.md @@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra The framework declares its contract version in `shopdb/__init__.py`: ```python -__contract_version__ = '0.2.0' +__contract_version__ = '0.4.0' ``` Each plugin's `manifest.json` declares the range of contract versions it supports: @@ -140,7 +140,9 @@ class ComputersPlugin(BasePlugin): ### `get_services() -> Dict[str, Type]` -Returns a dict of service-name to service-class. Other plugins can request services via the plugin manager. +Returns a dict of service-name to service-class. Another plugin obtains one via +`plugin_manager.get_service('')`, which searches enabled plugins and +returns the registered class/factory (or None). ```python from .services import ZabbixService @@ -166,6 +168,10 @@ class NotificationsPlugin(BasePlugin): }] ``` +Consumed by `GET /api/dashboard/widgets`, which merges widgets from all enabled +plugins sorted by `position` (disabled plugins are skipped; a broken plugin is +isolated in prod, re-raised in dev/test). + ### `get_navigation_items() -> List[Dict]` Returns navigation menu items. @@ -181,24 +187,10 @@ class ComputersPlugin(BasePlugin): }] ``` -### `get_searchable_fields() -> List[Dict]` - -Declares fields the plugin contributes to global search. - -```python -from .models import Computer - -class ComputersPlugin(BasePlugin): - def get_searchable_fields(self): - return [{ - 'model': Computer, - 'search_fields': ['hostname', 'serialnumber', 'currentuser'], - 'result_type': 'computer', - 'url_template': '/computers/{id}', - 'title_field': 'hostname', - 'subtitle_field': 'currentuser', - }] -``` +> Removed in contract 0.4.0: `get_searchable_fields`. Global search +> (`/api/search`) is a core concern that queries the asset model directly and +> already covers every bundled asset type; no plugin ever implemented the hook. +> Search honors runtime plugin enable/disable. ### `get_collector_schema() -> Optional[Dict]` @@ -222,6 +214,31 @@ class ComputersPlugin(BasePlugin): If the hook returns `None` (the default), no collector endpoint is registered. +### `apply_collector_payload(payload: Dict) -> Dict` + +Companion to `get_collector_schema` (ADR-006). The generic +`/api/collector/` endpoint calls this after the payload passes +identity validation, to idempotently upsert an asset. Return a dict with at +least `action` (`created` | `updated` | `noop`), `assetid`, and `warnings` +(list). + +This is a CONDITIONAL hook: it is only required when `get_collector_schema` +returns non-None. The BasePlugin default raises `NotImplementedError` (the +dispatcher turns that into a 500), so a plugin that declares a schema but +forgets the upsert fails loud. Plugins with no collector schema never need it. +The `test_schema_declaring_plugins_implement_apply` contract test enforces the +pairing. + +```python +def apply_collector_payload(self, payload): + host = payload['hostname'] + comp = Computer.query.filter(Computer.hostname.ilike(host)).first() + action = 'updated' if comp else 'created' + # ... create-or-update Asset + extension ... + db.session.commit() + return {'action': action, 'assetid': comp.assetid, 'warnings': []} +``` + ## Lifecycle hooks These run when the plugin's installation state changes. All optional. @@ -233,6 +250,34 @@ These run when the plugin's installation state changes. All optional. | `on_enable(app)` | When the plugin is enabled at runtime | Subscribe to events, warm caches | | `on_disable(app)` | When the plugin is disabled at runtime | Unsubscribe, drain queues | +## The import surface (`shopdb.api`) + +`shopdb.api` is the ONLY core module a plugin may import from (besides +`shopdb.plugins.base` for `BasePlugin` / `PluginMeta`). Importing internal +paths like `shopdb.core.models.*`, `shopdb.extensions`, or `shopdb.utils.*` +is a contract violation and fails the test +`tests/test_plugin_contract.py::test_plugins_only_import_contract_surface`. + +What `shopdb.api` exposes: + +- Infrastructure: `db`, `cache` +- Model bases: `BaseModel`, `AuditMixin` +- Core models: `Asset`, `AssetType`, `AssetStatus`, `Vendor`, `Model`, + `Communication`, `CommunicationType`, `Location`, `Setting`, `AuditLog`, + `Application`, `AppVersion`, `OperatingSystem` +- Responses: `success_response`, `error_response`, `paginated_response`, + `ErrorCodes` +- Pagination: `get_pagination_params`, `paginate_query` +- Helpers: `audit_log`, `resolve_asset_position` +- Legacy employee directory: `employee_connection` + +```python +from shopdb.api import db, Asset, AssetType, success_response, paginate_query +``` + +Adding a name to `shopdb.api` is an additive (minor) contract bump; removing +one is breaking (major). See ADR-002. + ## Helpers exposed to plugins The framework provides helper APIs in `shopdb.api` (the public namespace). diff --git a/docs/PLUGIN-QUICKSTART.md b/docs/PLUGIN-QUICKSTART.md index 1c38c6b..04b0ca6 100644 --- a/docs/PLUGIN-QUICKSTART.md +++ b/docs/PLUGIN-QUICKSTART.md @@ -119,10 +119,9 @@ Override hooks on the plugin class as needed. See [PLUGIN-HOOKS.md](PLUGIN-HOOKS | Hook | Adds | |------|------| -| `get_searchable_fields` | Plugin contributes to the global search endpoint | | `get_navigation_items` | Plugin shows up in the sidebar nav | | `get_dashboard_widgets` | Plugin's dashboard widget appears on the home page | -| `get_collector_schema` | Plugin accepts external pushes at `/api/collector/` | +| `get_collector_schema` + `apply_collector_payload` | Plugin accepts external pushes at `/api/collector/` | Each hook has a default that does nothing. Override only what your plugin needs. diff --git a/docs/adr/ADR-001-asset-as-platform-contract.md b/docs/adr/ADR-001-asset-as-platform-contract.md index ff703f8..bb47159 100644 --- a/docs/adr/ADR-001-asset-as-platform-contract.md +++ b/docs/adr/ADR-001-asset-as-platform-contract.md @@ -36,10 +36,29 @@ The following are the public, versioned surface. Plugin authors may depend on th - `AuditLog` API: `audit_log(action, entitytype, entityid, ...)` for plugins to record audit entries with consistent schema - `Setting` API: `plugin.get_setting(key)` and `plugin.set_setting(key, value)` for plugin-scoped config persisted via the core `Setting` model +- `resolve_asset_position(asset)` for the documented position-resolution algorithm + +#### Import surface (`shopdb.api`) - expanded in __contract_version__ 0.3.0 + +`shopdb.api` is the ONLY core module plugins may import (plus `shopdb.plugins.base` +for the ABC). Deep imports of `shopdb.core.*`, `shopdb.extensions`, or +`shopdb.utils.*` are contract violations enforced by the +`test_plugins_only_import_contract_surface` test. The surface re-exports: + +- Infrastructure: `db`, `cache` +- Model bases: `BaseModel`, `AuditMixin` +- Core models: `Asset`, `AssetType`, `AssetStatus`, `Vendor`, `Model`, + `Communication`, `CommunicationType`, `Location`, `Setting`, `AuditLog`, + `Application`, `AppVersion`, `OperatingSystem` +- Responses: `success_response`, `error_response`, `paginated_response`, `ErrorCodes` +- Pagination: `get_pagination_params`, `paginate_query` +- Helpers: `audit_log`, `resolve_asset_position`; legacy `employee_connection` + +Adding a name here is a minor (additive) contract change; removing one is major. #### Plugin contract -- `BasePlugin` ABC and its hooks (search, navigation, dashboard, relationships, collector schema) +- `BasePlugin` ABC and its hooks (navigation, dashboard widgets, collector schema + `apply_collector_payload`). Note: `get_searchable_fields` was removed in contract 0.4.0 - global search is a core concern over the asset model, not a per-plugin hook. #### Excluded from the contract for v1 diff --git a/docs/adr/ADR-004-deployment-topology.md b/docs/adr/ADR-004-deployment-topology.md index ac38049..0bb2340 100644 --- a/docs/adr/ADR-004-deployment-topology.md +++ b/docs/adr/ADR-004-deployment-topology.md @@ -56,6 +56,19 @@ The framework provides: 1. **Multi-tenant single instance.** Lower operational overhead at scale, easier cross-site reporting, but adds significant code complexity and risk: every query needs a tenant filter, auth gets complex, schema migrations affect every site at once, and a bug at one site can leak data across sites. Rejected for v1; revisit if and only if more than five sites adopt and operational overhead becomes painful. 2. **Hybrid: per-site DB but central app server.** Adds the operational complexity of multi-tenancy without isolating the failure domain (one app crash = all sites down). Rejected. +## Migration strategy (resolved) + +Deploys run a single core Alembic chain: `flask db upgrade`. Bundled plugins do +NOT carry their own migration chains - their tables are folded into the core +chain (migration `7c04_fold_plugin_schema`). This was a deliberate resolution of +the Phase 7B footgun where bundled-plugin baselines and the core baseline both +created the same tables, so `flask plugin upgrade-all` would conflict. A fresh +`flask db upgrade` reproduces the live schema exactly (verified on a scratch DB). + +External (out-of-tree) plugins per ADR-003 may still ship their own migrations; +the framework supports per-plugin chains for them. Only the in-tree bundled +plugins are consolidated into core. + ## Open questions - Should the framework provide an optional **read-only fleet roll-up** mode where a "central" instance can pull aggregate metrics from each site's API? Defer. Out of scope for v1. diff --git a/docs/adr/ADR-006-collector-contract.md b/docs/adr/ADR-006-collector-contract.md index c18b477..e277f5a 100644 --- a/docs/adr/ADR-006-collector-contract.md +++ b/docs/adr/ADR-006-collector-contract.md @@ -15,7 +15,7 @@ This calls for a generalizable contract: any plugin that wants to accept externa ## Decision -`BasePlugin` gets one new optional hook: +`BasePlugin` gets two new hooks (added in __contract_version__ 0.2.x -> the surface is carried at 0.3.0): ```python def get_collector_schema(self) -> Optional[dict]: @@ -29,9 +29,26 @@ def get_collector_schema(self) -> Optional[dict]: - 'fields': JSON Schema definitions for the rest of the payload. """ return None + +def apply_collector_payload(self, payload: dict) -> dict: + """Idempotently upsert an asset from a validated collector payload. + + Called by /api/collector/ after identity validation. + CONDITIONAL hook: required only when get_collector_schema returns + non-None. Default raises NotImplementedError (the dispatcher returns + 500) so a schema-without-upsert fails loud. Returns a dict with at + least 'action' ('created'|'updated'|'noop'), 'assetid', 'warnings'. + """ + raise NotImplementedError ``` -Plugin loader auto-registers an endpoint at `/api/collector/` for each plugin returning a schema. Auth is API-key, separate from JWT. Per-plugin keys via env vars: +The pairing (schema present => apply implemented) is enforced by the +`test_schema_declaring_plugins_implement_apply` contract test. + +A single dynamic dispatch route `/api/collector/` serves every +plugin that returns a schema (rather than registering a blueprint per plugin), +because Flask forbids `register_blueprint` after the first request and plugins +can be enabled at runtime. Auth is API-key, separate from JWT. Per-plugin keys via env vars: - `COLLECTOR_API_KEY_` (preferred, plugin-specific) - `COLLECTOR_API_KEY` (fallback, shared) @@ -125,7 +142,7 @@ Migration path: ## References - `shopdb/core/api/collector.py` (legacy endpoint to be removed) -- `shopdb/plugins/base.py` (`get_collector_schema` hook to be added) +- `shopdb/plugins/base.py` (`get_collector_schema` + `apply_collector_payload` hooks) - ADR-001 (asset model the collectors target) - ADR-002 (collector schema is part of plugin contract; changes to the hook signature are major bumps) - The PXE project (`/home/camp/projects/pxe/`) which feeds the computers collector diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index e884c03..f590a92 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -58,38 +58,6 @@ export const authApi = { } } -// Machines API (legacy - use equipmentApi or computersApi instead) -export const machinesApi = { - list(params = {}) { - return api.get('/machines', { params }) - }, - get(id) { - return api.get(`/machines/${id}`) - }, - create(data) { - return api.post('/machines', data) - }, - update(id, data) { - return api.put(`/machines/${id}`, data) - }, - delete(id) { - return api.delete(`/machines/${id}`) - }, - updateCommunication(id, data) { - return api.put(`/machines/${id}/communication`, data) - }, - // Relationships - getRelationships(id) { - return api.get(`/machines/${id}/relationships`) - }, - createRelationship(id, data) { - return api.post(`/machines/${id}/relationships`, data) - }, - deleteRelationship(relationshipId) { - return api.delete(`/machines/relationships/${relationshipId}`) - } -} - // Equipment API (plugin) export const equipmentApi = { list(params = {}) { @@ -176,10 +144,10 @@ export const computersApi = { // Relationship Types API export const relationshipTypesApi = { list() { - return api.get('/machines/relationshiptypes') + return api.get('/assets/relationshiptypes') }, create(data) { - return api.post('/machines/relationshiptypes', data) + return api.post('/assets/relationshiptypes', data) } } @@ -199,25 +167,6 @@ export const machinetypesApi = { } } -// Statuses API -export const statusesApi = { - list(params = {}) { - return api.get('/statuses', { params }) - }, - get(id) { - return api.get(`/statuses/${id}`) - }, - create(data) { - return api.post('/statuses', data) - }, - update(id, data) { - return api.put(`/statuses/${id}`, data) - }, - delete(id) { - return api.delete(`/statuses/${id}`) - } -} - // Vendors API export const vendorsApi = { list(params = {}) { @@ -253,6 +202,11 @@ export const locationsApi = { }, delete(id) { return api.delete(`/locations/${id}`) + }, + types: { + list() { + return api.get('/locations/types') + } } } @@ -264,9 +218,26 @@ export const printersApi = { get(id) { return api.get(`/printers/${id}`) }, + // create/update write asset core + printer extension in one call (the + // printers plugin owns both). Use these instead of the legacy machinesApi. + create(data) { + return api.post('/printers', data) + }, + update(id, data) { + return api.put(`/printers/${id}`, data) + }, updateExtension(id, data) { return api.put(`/printers/${id}/printerdata`, data) }, + // printer sub-types (Laser, Inkjet, Label, Card, Wide Format, ...) + types: { + list(params = {}) { + return api.get('/printers/types', { params }) + }, + create(data) { + return api.post('/printers/types', data) + } + }, updateCommunication(id, data) { return api.put(`/printers/${id}/communication`, data) }, @@ -279,6 +250,12 @@ export const printersApi = { lowSupplies() { return api.get('/printers/lowsupplies') }, + refreshSupplies() { + return api.post('/printers/supplies/refresh') + }, + lookup({ ip, fqdn } = {}) { + return api.get('/printers/lookup', { params: { ip, fqdn } }) + }, dashboardSummary() { return api.get('/printers/dashboard/summary') }, @@ -303,6 +280,27 @@ export const printersApi = { create(data) { return api.post('/printers/supplytypes', data) } + }, + // model -> toner/drum/waste part-number management + modelSupplies: { + meta() { + return api.get('/printers/supplies/meta') + }, + listModels(params = {}) { + return api.get('/printers/models', { params }) + }, + list(modelnumberid) { + return api.get(`/printers/models/${modelnumberid}/supplies`) + }, + create(modelnumberid, data) { + return api.post(`/printers/models/${modelnumberid}/supplies`, data) + }, + update(modelsupplyid, data) { + return api.put(`/printers/supplies/${modelsupplyid}`, data) + }, + delete(modelsupplyid) { + return api.delete(`/printers/supplies/${modelsupplyid}`) + } } } @@ -321,6 +319,23 @@ export const modelsApi = { list(params = {}) { return api.get('/models', { params }) }, + // Backend caps perpage at 100, so page through every model. Returns the + // full array directly (not an axios response). Use in forms whose model + // dropdown must include the editing record's model regardless of page. + async listAll() { + const first = await api.get('/models', { params: { perpage: 100, page: 1 } }) + let items = first.data.data || [] + const totalpages = first.data.meta?.pagination?.totalpages || 1 + if (totalpages > 1) { + const rest = await Promise.all( + Array.from({ length: totalpages - 1 }, (_, i) => + api.get('/models', { params: { perpage: 100, page: i + 2 } }) + ) + ) + rest.forEach(r => { items = items.concat(r.data.data || []) }) + } + return items + }, get(id) { return api.get(`/models/${id}`) }, @@ -335,25 +350,6 @@ export const modelsApi = { } } -// PC Types API -export const pctypesApi = { - list(params = {}) { - return api.get('/pctypes', { params }) - }, - get(id) { - return api.get(`/pctypes/${id}`) - }, - create(data) { - return api.post('/pctypes', data) - }, - update(id, data) { - return api.put(`/pctypes/${id}`, data) - }, - delete(id) { - return api.delete(`/pctypes/${id}`) - } -} - // Operating Systems API export const operatingsystemsApi = { list(params = {}) { @@ -528,8 +524,17 @@ export const assetsApi = { } }, statuses: { - list() { - return api.get('/assets/statuses') + list(params = {}) { + return api.get('/assets/statuses', { params }) + }, + create(data) { + return api.post('/assets/statuses', data) + }, + update(id, data) { + return api.put(`/assets/statuses/${id}`, data) + }, + delete(id) { + return api.delete(`/assets/statuses/${id}`) } } } @@ -669,6 +674,15 @@ export const employeesApi = { export const businessUnitsApi = businessunitsApi // System Settings API +export const pluginsApi = { + list() { + return api.get('/plugins') + }, + setEnabled(name, enabled) { + return api.put(`/plugins/${name}`, { enabled }) + } +} + export const settingsApi = { list(params = {}) { return api.get('/settings', { params }) diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index 148299e..681aeb3 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -372,6 +372,15 @@ th, td { border-top: 1px solid var(--border); } +/* Cap a long free-text column (e.g. Description) so it truncates with an + ellipsis instead of widening the row and pushing the Actions column out of + view. Pair with a title attribute to show the full text on hover. */ +td.cell-truncate { + max-width: 32rem; + overflow: hidden; + text-overflow: ellipsis; +} + th { font-weight: 600; font-size: 11px; diff --git a/frontend/src/composables/identifierSettings.js b/frontend/src/composables/identifierSettings.js new file mode 100644 index 0000000..a9c72b4 --- /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_(.+?)(?:_(equipment|computer|printer|network_device))?_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/router/routes/core.js b/frontend/src/router/routes/core.js index 0dc4a0e..2fef2dd 100644 --- a/frontend/src/router/routes/core.js +++ b/frontend/src/router/routes/core.js @@ -92,6 +92,12 @@ export default [ component: () => import('../../views/settings/SystemSettings.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', diff --git a/frontend/src/router/routes/printers.js b/frontend/src/router/routes/printers.js index 0e50cfc..2dd83d8 100644 --- a/frontend/src/router/routes/printers.js +++ b/frontend/src/router/routes/printers.js @@ -23,5 +23,12 @@ export default [ name: 'printer-edit', component: () => import('../../views/printers/PrinterForm.vue'), meta: { requiresAuth: true } + }, + // printer-specific settings + { + path: 'settings/modelsupplies', + name: 'model-supplies', + component: () => import('../../views/settings/ModelSuppliesList.vue'), + meta: { requiresAuth: true } } ] diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index ae70c06..d0821c0 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -65,18 +65,18 @@ - - + + - - - - - + + + + + - + - +
NameCategoryAsset # TypeStatus Business Unit
{{ machine.machinenumber || machine.hostname || machine.alias || '-' }}{{ machine.category || '-' }}{{ machine.machinetype || '-' }}{{ machine.businessunit || '-' }}
{{ machine.assetnumber || machine.name || '-' }}{{ machine.assettypename || '-' }}{{ machine.statusname || '-' }}{{ machine.businessunitname || '-' }}
@@ -93,7 +93,7 @@ + + diff --git a/frontend/src/views/settings/PCTypesList.vue b/frontend/src/views/settings/PCTypesList.vue index d60ff3d..fc674f0 100644 --- a/frontend/src/views/settings/PCTypesList.vue +++ b/frontend/src/views/settings/PCTypesList.vue @@ -1,177 +1,126 @@ - - - + + + diff --git a/frontend/src/views/settings/PluginsList.vue b/frontend/src/views/settings/PluginsList.vue new file mode 100644 index 0000000..9ea1d25 --- /dev/null +++ b/frontend/src/views/settings/PluginsList.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/frontend/src/views/settings/SettingsIndex.vue b/frontend/src/views/settings/SettingsIndex.vue index 5bb3241..1068928 100644 --- a/frontend/src/views/settings/SettingsIndex.vue +++ b/frontend/src/views/settings/SettingsIndex.vue @@ -27,6 +27,12 @@

Manage equipment models by vendor

+ +
+

Model Toners and Supplies

+

Map toner, drum, and waste part numbers to printer models

+
+

Machine Types

@@ -69,6 +75,12 @@

Configure integrations and system options

+ +
+

Plugins

+

Enable or disable installed plugins

+
+

Audit Logs

@@ -85,7 +97,7 @@ diff --git a/frontend/src/views/settings/UsersList.vue b/frontend/src/views/settings/UsersList.vue index 116ec31..6b6b8c4 100644 --- a/frontend/src/views/settings/UsersList.vue +++ b/frontend/src/views/settings/UsersList.vue @@ -87,7 +87,7 @@ {{ role.rolename }}
{{ role.description || '-' }}{{ role.description || '-' }} All permissions {{ role.permissions.length }} permissions diff --git a/frontend/src/views/settings/VLANsList.vue b/frontend/src/views/settings/VLANsList.vue index 3019937..a8a9952 100644 --- a/frontend/src/views/settings/VLANsList.vue +++ b/frontend/src/views/settings/VLANsList.vue @@ -50,7 +50,7 @@ - {{ vlan.description || '-' }}{{ vlan.description || '-' }} 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/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index fcdcc11..787cd7e 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -3,15 +3,7 @@ from flask import Blueprint, request from flask_jwt_extended import jwt_required -from shopdb.extensions import db -from shopdb.core.models import Asset, AssetType, OperatingSystem, 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.api import db, Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog, Communication, CommunicationType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from ..models import Computer, ComputerType, ComputerInstalledApp @@ -162,19 +154,19 @@ def list_computers(): ) # Computer type filter - if type_id := request.args.get('type_id'): + 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('os_id'): + 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('location_id'): + 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('businessunit_id'): + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): query = query.filter(Asset.businessunitid == int(bu_id)) # Shopfloor filter @@ -225,6 +217,10 @@ def get_computer(computer_id: int): 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() + ] return success_response(result) @@ -321,6 +317,8 @@ def create_computer(): 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'), @@ -339,6 +337,8 @@ def create_computer(): 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'), @@ -350,6 +350,17 @@ def create_computer(): 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, + )) + # Audit log AuditLog.log('created', 'Computer', entityid=comp.computerid, entityname=data.get('hostname') or data['assetnumber']) @@ -404,7 +415,8 @@ def update_computer(computer_id: int): changes = {} # Update asset fields - asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', + asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference', + 'maintenancereference', 'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] for key in asset_fields: if key in data: @@ -415,8 +427,9 @@ def update_computer(computer_id: int): setattr(asset, key, data[key]) # Update computer fields - computer_fields = ['computertypeid', 'hostname', 'osid', 'loggedinuser', - 'lastreporteddate', 'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor'] + computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid', + 'modelnumberid', 'loggedinuser', 'lastreporteddate', + 'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor'] for key in computer_fields: if key in data: old_val = getattr(comp, key) @@ -425,6 +438,23 @@ def update_computer(computer_id: int): 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 + # Audit log if there were changes if changes: AuditLog.log('updated', 'Computer', entityid=comp.computerid, diff --git a/plugins/computers/migrations/alembic.ini b/plugins/computers/migrations/alembic.ini deleted file mode 100644 index 0775ad4..0000000 --- a/plugins/computers/migrations/alembic.ini +++ /dev/null @@ -1,30 +0,0 @@ -[alembic] -script_location = . -prepend_sys_path = . -file_template = %%(rev)s_%%(slug)s - -[logging] -keys = root - -[loggers] -keys = root - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = INFO -handlers = console -qualname = - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = INFO -formatter = generic - -[formatter_generic] -format = %%(levelname)-5.5s [%%(name)s] %%(message)s diff --git a/plugins/computers/migrations/env.py b/plugins/computers/migrations/env.py deleted file mode 100644 index 037991d..0000000 --- a/plugins/computers/migrations/env.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Alembic env.py for the computers plugin. - -Thin shim that sets PLUGIN_NAME then delegates to the shared template at -shopdb.plugins.alembic_template, which filters MetaData to only this -plugin's tables and runs Alembic against the Flask app's engine. -""" -import os -os.environ['PLUGIN_NAME'] = 'computers' - -from shopdb.plugins.alembic_template import run_migrations - -run_migrations() diff --git a/plugins/computers/migrations/script.py.mako b/plugins/computers/migrations/script.py.mako deleted file mode 100644 index f230367..0000000 --- a/plugins/computers/migrations/script.py.mako +++ /dev/null @@ -1,23 +0,0 @@ -"""${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 = ${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_baseline.py b/plugins/computers/migrations/versions/0001_baseline.py deleted file mode 100644 index f2c21b1..0000000 --- a/plugins/computers/migrations/versions/0001_baseline.py +++ /dev/null @@ -1,27 +0,0 @@ -"""computers plugin: baseline schema - -Creates every table owned by the computers plugin per -shopdb.plugins.alembic_template.PLUGIN_TABLE_OWNERS. The table definitions -are derived from the SQLAlchemy models at migration runtime so this stays -in lockstep with the model layer without duplication. - -Revision ID: 0001_baseline_computers -Revises: -Create Date: 2026-05-30 - -""" -from shopdb.plugins.alembic_template import create_plugin_tables, drop_plugin_tables - - -revision = '0001_baseline_computers' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - create_plugin_tables('computers') - - -def downgrade(): - drop_plugin_tables('computers') diff --git a/plugins/computers/models/computer.py b/plugins/computers/models/computer.py index 5e149fd..7c2e6f1 100644 --- a/plugins/computers/models/computer.py +++ b/plugins/computers/models/computer.py @@ -1,184 +1,203 @@ -"""Computer plugin models.""" - -from shopdb.extensions import db -from shopdb.core.models.base import BaseModel - - -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') - - 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), - index=True, - comment='Network hostname' - ) - - # Operating system - osid = db.Column( - db.Integer, - db.ForeignKey('operatingsystems.osid'), - 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 features - isvnc = db.Column( - db.Boolean, - default=False, - comment='VNC remote access enabled' - ) - iswinrm = db.Column( - db.Boolean, - default=False, - comment='WinRM enabled' - ) - - # Classification flags - isshopfloor = db.Column( - db.Boolean, - default=False, - comment='Shopfloor PC (vs office PC)' - ) - - # 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') - - # Installed applications (one-to-many) - installedapps = db.relationship( - 'ComputerInstalledApp', - back_populates='computer', - cascade='all, delete-orphan', - lazy='dynamic' - ) - - __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 - - 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 - ) - isactive = db.Column(db.Boolean, default=True, nullable=False) - installeddate = db.Column(db.DateTime, default=db.func.now()) - - # 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): - """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"" +"""Computer plugin models.""" + +from shopdb.api import db, BaseModel + + +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') + + 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), + index=True, + comment='Network hostname' + ) + + # Operating system + osid = db.Column( + db.Integer, + db.ForeignKey('operatingsystems.osid'), + nullable=True + ) + + # Hardware make/model (PCs carry vendor + model like equipment) + 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 features + isvnc = db.Column( + db.Boolean, + default=False, + comment='VNC remote access enabled' + ) + iswinrm = db.Column( + db.Boolean, + default=False, + comment='WinRM enabled' + ) + + # Classification flags + isshopfloor = db.Column( + db.Boolean, + default=False, + comment='Shopfloor PC (vs office PC)' + ) + + # 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' + ) + + __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 + + 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=db.func.now()) + + # 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): + """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/plugin.py b/plugins/computers/plugin.py index f42f1d3..8e0508c 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -1,209 +1,306 @@ -"""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.extensions import db -from shopdb.core.models import AssetType, AssetStatus - -from .models import Computer, ComputerType, ComputerInstalledApp -from .api import computers_bp - -logger = logging.getLogger(__name__) - - -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] - - def init_app(self, app: Flask, db_instance) -> None: - """Initialize plugin with Flask app.""" - logger.info(f"Computers 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_computer_types() - 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.core.models 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 - shopfloor = db.session.query(Computer).join(Asset).filter( - Asset.isactive == True, - Computer.isshopfloor == True - ).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, - }, - ] +"""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 +from .api import computers_bp + +logger = logging.getLogger(__name__) + + +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] + + 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).""" + return { + 'identityfield': 'hostname', + 'fields': { + 'hostname': {'type': 'string', 'required': True}, + 'serialnumber': {'type': 'string'}, + 'currentuser': {'type': 'string'}, + 'lastboottime': {'type': 'string', 'format': 'date-time'}, + 'ipaddress': {'type': 'string'}, + 'installedsoftware': { + 'type': 'array', + 'items': {'name': 'string', 'version': 'string'}, + }, + }, + } + + def apply_collector_payload(self, payload: Dict) -> Dict: + """Idempotent upsert of a PC from a collector payload (by hostname).""" + from datetime import datetime + from shopdb.api import Asset, Application, Communication, CommunicationType + + warnings = [] + hostname = (payload.get('hostname') or '').strip() + if not hostname: + raise ValueError('hostname is required') + + 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=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' + + comp.lastreporteddate = datetime.utcnow() + if payload.get('lastboottime'): + try: + comp.lastboottime = datetime.fromisoformat( + payload['lastboottime'].replace('Z', '+00:00')) + except (ValueError, AttributeError): + warnings.append('lastboottime not parseable') + if payload.get('currentuser'): + comp.loggedinuser = payload['currentuser'] + if payload.get('serialnumber') and comp.asset: + comp.asset.serialnumber = payload['serialnumber'] + + 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)) + + db.session.commit() + return { + 'action': action, + 'assetid': comp.assetid, + 'identityvalue': hostname, + 'warnings': warnings, + } + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensure_asset_type() + self._ensure_computer_types() + 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 + shopfloor = db.session.query(Computer).join(Asset).filter( + Asset.isactive == True, + Computer.isshopfloor == True + ).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, + }, + ] diff --git a/plugins/equipment/api/routes.py b/plugins/equipment/api/routes.py index 1b0a533..8ca5156 100644 --- a/plugins/equipment/api/routes.py +++ b/plugins/equipment/api/routes.py @@ -3,15 +3,7 @@ from flask import Blueprint, request from flask_jwt_extended import jwt_required -from shopdb.extensions import db -from shopdb.core.models import Asset, AssetType, Vendor, Model, 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.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from ..models import Equipment, EquipmentType @@ -160,19 +152,19 @@ def list_equipment(): ) # Equipment type filter - if type_id := request.args.get('type_id'): + if type_id := request.args.get('typeid', request.args.get('type_id')): query = query.filter(Equipment.equipmenttypeid == int(type_id)) # Vendor filter - if vendor_id := request.args.get('vendor_id'): + if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')): query = query.filter(Equipment.vendorid == int(vendor_id)) # Location filter - if location_id := request.args.get('location_id'): + 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('businessunit_id'): + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): query = query.filter(Asset.businessunitid == int(bu_id)) # Sorting @@ -282,6 +274,8 @@ def create_equipment(): asset = Asset( assetnumber=data['assetnumber'], name=data.get('name'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), serialnumber=data.get('serialnumber'), assettypeid=equipment_type.assettypeid, statusid=data.get('statusid', 1), @@ -357,8 +351,10 @@ def update_equipment(equipment_id: int): changes = {} # Update asset fields - asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', - 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] + 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) @@ -442,7 +438,7 @@ def dashboard_summary(): ).all() # Count by status - from shopdb.core.models import AssetStatus + from shopdb.api import AssetStatus by_status = db.session.query( AssetStatus.status, db.func.count(Equipment.equipmentid) diff --git a/plugins/equipment/migrations/alembic.ini b/plugins/equipment/migrations/alembic.ini deleted file mode 100644 index 0775ad4..0000000 --- a/plugins/equipment/migrations/alembic.ini +++ /dev/null @@ -1,30 +0,0 @@ -[alembic] -script_location = . -prepend_sys_path = . -file_template = %%(rev)s_%%(slug)s - -[logging] -keys = root - -[loggers] -keys = root - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = INFO -handlers = console -qualname = - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = INFO -formatter = generic - -[formatter_generic] -format = %%(levelname)-5.5s [%%(name)s] %%(message)s diff --git a/plugins/equipment/migrations/env.py b/plugins/equipment/migrations/env.py deleted file mode 100644 index 42ec87e..0000000 --- a/plugins/equipment/migrations/env.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Alembic env.py for the equipment plugin. - -Thin shim that sets PLUGIN_NAME then delegates to the shared template at -shopdb.plugins.alembic_template, which filters MetaData to only this -plugin's tables and runs Alembic against the Flask app's engine. -""" -import os -os.environ['PLUGIN_NAME'] = 'equipment' - -from shopdb.plugins.alembic_template import run_migrations - -run_migrations() diff --git a/plugins/equipment/migrations/script.py.mako b/plugins/equipment/migrations/script.py.mako deleted file mode 100644 index f230367..0000000 --- a/plugins/equipment/migrations/script.py.mako +++ /dev/null @@ -1,23 +0,0 @@ -"""${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 = ${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/equipment/migrations/versions/0001_baseline.py b/plugins/equipment/migrations/versions/0001_baseline.py deleted file mode 100644 index 44e7837..0000000 --- a/plugins/equipment/migrations/versions/0001_baseline.py +++ /dev/null @@ -1,27 +0,0 @@ -"""equipment plugin: baseline schema - -Creates every table owned by the equipment plugin per -shopdb.plugins.alembic_template.PLUGIN_TABLE_OWNERS. The table definitions -are derived from the SQLAlchemy models at migration runtime so this stays -in lockstep with the model layer without duplication. - -Revision ID: 0001_baseline_equipment -Revises: -Create Date: 2026-05-30 - -""" -from shopdb.plugins.alembic_template import create_plugin_tables, drop_plugin_tables - - -revision = '0001_baseline_equipment' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - create_plugin_tables('equipment') - - -def downgrade(): - drop_plugin_tables('equipment') diff --git a/plugins/equipment/models/equipment.py b/plugins/equipment/models/equipment.py index fe0d2c0..565ed64 100644 --- a/plugins/equipment/models/equipment.py +++ b/plugins/equipment/models/equipment.py @@ -1,133 +1,132 @@ -"""Equipment plugin models.""" - -from shopdb.extensions import db -from shopdb.core.models.base import BaseModel - - -class EquipmentType(BaseModel): - """ - Equipment type classification. - - Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc. - """ - __tablename__ = 'equipmenttypes' - - equipmenttypeid = db.Column(db.Integer, primary_key=True) - equipmenttype = 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') - - def __repr__(self): - return f"" - - -class Equipment(BaseModel): - """ - Equipment-specific extension data. - - Links to core Asset table via assetid. - Stores equipment-specific fields like type, model, vendor, etc. - """ - __tablename__ = 'equipment' - - equipmentid = 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 - ) - - # Equipment classification - equipmenttypeid = db.Column( - db.Integer, - db.ForeignKey('equipmenttypes.equipmenttypeid'), - 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 - ) - - # Equipment-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 equipment)' - ) - - # 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('equipment', uselist=False, lazy='joined') - ) - equipmenttype = db.relationship('EquipmentType', backref='equipment') - vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items') - model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items') - controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers') - controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models') - - __table_args__ = ( - db.Index('idx_equipment_type', 'equipmenttypeid'), - db.Index('idx_equipment_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.equipmenttype: - result['equipmenttypename'] = self.equipmenttype.equipmenttype - 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 +"""Equipment plugin models.""" + +from shopdb.api import db, BaseModel + + +class EquipmentType(BaseModel): + """ + Equipment type classification. + + Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc. + """ + __tablename__ = 'equipmenttypes' + + equipmenttypeid = db.Column(db.Integer, primary_key=True) + equipmenttype = 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') + + def __repr__(self): + return f"" + + +class Equipment(BaseModel): + """ + Equipment-specific extension data. + + Links to core Asset table via assetid. + Stores equipment-specific fields like type, model, vendor, etc. + """ + __tablename__ = 'equipment' + + equipmentid = 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 + ) + + # Equipment classification + equipmenttypeid = db.Column( + db.Integer, + db.ForeignKey('equipmenttypes.equipmenttypeid'), + 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 + ) + + # Equipment-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 equipment)' + ) + + # 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('equipment', uselist=False, lazy='joined') + ) + equipmenttype = db.relationship('EquipmentType', backref='equipment') + vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items') + model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items') + controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers') + controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models') + + __table_args__ = ( + db.Index('idx_equipment_type', 'equipmenttypeid'), + db.Index('idx_equipment_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.equipmenttype: + result['equipmenttypename'] = self.equipmenttype.equipmenttype + 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/equipment/plugin.py b/plugins/equipment/plugin.py index bbe5b06..dc576a8 100644 --- a/plugins/equipment/plugin.py +++ b/plugins/equipment/plugin.py @@ -1,220 +1,219 @@ -"""Equipment 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.extensions import db -from shopdb.core.models import AssetType, AssetStatus - -from .models import Equipment, EquipmentType -from .api import equipment_bp - -logger = logging.getLogger(__name__) - - -class EquipmentPlugin(BasePlugin): - """ - Equipment plugin - manages manufacturing equipment assets. - - Equipment includes CNCs, CMMs, lathes, grinders, EDMs, part markers, etc. - Uses the new Asset architecture with Equipment 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', 'equipment'), - version=self._manifest.get('version', '1.0.0'), - description=self._manifest.get( - 'description', - 'Equipment 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/equipment'), - ) - - def get_blueprint(self) -> Optional[Blueprint]: - """Return Flask Blueprint with API routes.""" - return equipment_bp - - def get_models(self) -> List[Type]: - """Return list of SQLAlchemy model classes.""" - return [Equipment, EquipmentType] - - def init_app(self, app: Flask, db_instance) -> None: - """Initialize plugin with Flask app.""" - logger.info(f"Equipment 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_equipment_types() - logger.info("Equipment plugin installed") - - def _ensure_asset_type(self) -> None: - """Ensure equipment asset type exists.""" - existing = AssetType.query.filter_by(assettype='equipment').first() - if not existing: - at = AssetType( - assettype='equipment', - pluginname='equipment', - tablename='equipment', - description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)', - icon='cog' - ) - db.session.add(at) - logger.debug("Created asset type: equipment") - 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_equipment_types(self) -> None: - """Ensure basic equipment types exist.""" - equipment_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 equipment', 'marker'), - ('Mill', 'Milling machine', 'mill'), - ('Press', 'Press machine', 'press'), - ('Robot', 'Industrial robot', 'robot'), - ('Other', 'Other equipment type', 'cog'), - ] - - for name, description, icon in equipment_types: - existing = EquipmentType.query.filter_by(equipmenttype=name).first() - if not existing: - et = EquipmentType( - equipmenttype=name, - description=description, - icon=icon - ) - db.session.add(et) - logger.debug(f"Created equipment type: {name}") - - db.session.commit() - - def on_uninstall(self, app: Flask) -> None: - """Called when plugin is uninstalled.""" - logger.info("Equipment plugin uninstalled") - - def get_cli_commands(self) -> List: - """Return CLI commands for this plugin.""" - - @click.group('equipment') - def equipmentcli(): - """Equipment plugin commands.""" - pass - - @equipmentcli.command('list-types') - def list_types(): - """List all equipment types.""" - from flask import current_app - - with current_app.app_context(): - types = EquipmentType.query.filter_by(isactive=True).all() - if not types: - click.echo('No equipment types found.') - return - - click.echo('Equipment Types:') - for t in types: - click.echo(f" [{t.equipmenttypeid}] {t.equipmenttype}") - - @equipmentcli.command('stats') - def stats(): - """Show equipment statistics.""" - from flask import current_app - from shopdb.core.models import Asset - - with current_app.app_context(): - total = db.session.query(Equipment).join(Asset).filter( - Asset.isactive == True - ).count() - - click.echo(f"Total active equipment: {total}") - - # By type - by_type = db.session.query( - EquipmentType.equipmenttype, - db.func.count(Equipment.equipmentid) - ).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid - ).join(Asset, Asset.assetid == Equipment.assetid - ).filter(Asset.isactive == True - ).group_by(EquipmentType.equipmenttype - ).all() - - if by_type: - click.echo("\nBy Type:") - for t, c in by_type: - click.echo(f" {t}: {c}") - - return [equipmentcli] - - def get_dashboard_widgets(self) -> List[Dict]: - """Return dashboard widget definitions.""" - return [ - { - 'name': 'Equipment Status', - 'component': 'EquipmentStatusWidget', - 'endpoint': '/api/equipment/dashboard/summary', - 'size': 'medium', - 'position': 5, - }, - ] - - def get_navigation_items(self) -> List[Dict]: - """Return navigation menu items.""" - return [ - { - 'name': 'Equipment', - 'icon': 'cog', - 'route': '/machines', - 'position': 10, - }, - ] +"""Equipment 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 Equipment, EquipmentType +from .api import equipment_bp + +logger = logging.getLogger(__name__) + + +class EquipmentPlugin(BasePlugin): + """ + Equipment plugin - manages manufacturing equipment assets. + + Equipment includes CNCs, CMMs, lathes, grinders, EDMs, part markers, etc. + Uses the new Asset architecture with Equipment 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', 'equipment'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Equipment 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/equipment'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return equipment_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [Equipment, EquipmentType] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Equipment 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_equipment_types() + logger.info("Equipment plugin installed") + + def _ensure_asset_type(self) -> None: + """Ensure equipment asset type exists.""" + existing = AssetType.query.filter_by(assettype='equipment').first() + if not existing: + at = AssetType( + assettype='equipment', + pluginname='equipment', + tablename='equipment', + description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)', + icon='cog' + ) + db.session.add(at) + logger.debug("Created asset type: equipment") + 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_equipment_types(self) -> None: + """Ensure basic equipment types exist.""" + equipment_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 equipment', 'marker'), + ('Mill', 'Milling machine', 'mill'), + ('Press', 'Press machine', 'press'), + ('Robot', 'Industrial robot', 'robot'), + ('Other', 'Other equipment type', 'cog'), + ] + + for name, description, icon in equipment_types: + existing = EquipmentType.query.filter_by(equipmenttype=name).first() + if not existing: + et = EquipmentType( + equipmenttype=name, + description=description, + icon=icon + ) + db.session.add(et) + logger.debug(f"Created equipment type: {name}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Equipment plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('equipment') + def equipmentcli(): + """Equipment plugin commands.""" + pass + + @equipmentcli.command('list-types') + def list_types(): + """List all equipment types.""" + from flask import current_app + + with current_app.app_context(): + types = EquipmentType.query.filter_by(isactive=True).all() + if not types: + click.echo('No equipment types found.') + return + + click.echo('Equipment Types:') + for t in types: + click.echo(f" [{t.equipmenttypeid}] {t.equipmenttype}") + + @equipmentcli.command('stats') + def stats(): + """Show equipment statistics.""" + from flask import current_app + from shopdb.api import Asset + + with current_app.app_context(): + total = db.session.query(Equipment).join(Asset).filter( + Asset.isactive == True + ).count() + + click.echo(f"Total active equipment: {total}") + + # By type + by_type = db.session.query( + EquipmentType.equipmenttype, + db.func.count(Equipment.equipmentid) + ).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid + ).join(Asset, Asset.assetid == Equipment.assetid + ).filter(Asset.isactive == True + ).group_by(EquipmentType.equipmenttype + ).all() + + if by_type: + click.echo("\nBy Type:") + for t, c in by_type: + click.echo(f" {t}: {c}") + + return [equipmentcli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Equipment Status', + 'component': 'EquipmentStatusWidget', + 'endpoint': '/api/equipment/dashboard/summary', + 'size': 'medium', + 'position': 5, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Equipment', + 'icon': 'cog', + 'route': '/machines', + 'position': 10, + }, + ] diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py index aaed6e1..649d0ee 100644 --- a/plugins/network/api/routes.py +++ b/plugins/network/api/routes.py @@ -3,15 +3,7 @@ from flask import Blueprint, request from flask_jwt_extended import jwt_required -from shopdb.extensions import db -from shopdb.core.models import Asset, AssetType, Vendor, 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.api import db, Asset, AssetType, Vendor, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN @@ -163,19 +155,19 @@ def list_network_devices(): ) # Type filter - if type_id := request.args.get('type_id'): + 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('vendor_id'): + 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('location_id'): + 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('businessunit_id'): + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): query = query.filter(Asset.businessunitid == int(bu_id)) # PoE filter @@ -207,7 +199,7 @@ def list_network_devices(): data = [] for netdev in items: item = netdev.asset.to_dict() if netdev.asset else {} - item['network_device'] = netdev.to_dict() + item['networkdevice'] = netdev.to_dict() data.append(item) return paginated_response(data, page, per_page, total) @@ -324,6 +316,8 @@ def create_network_device(): 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'), @@ -406,7 +400,8 @@ def update_network_device(device_id: int): changes = {} # Update asset fields - asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', + asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference', + 'maintenancereference', 'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] for key in asset_fields: if key in data: diff --git a/plugins/network/migrations/alembic.ini b/plugins/network/migrations/alembic.ini deleted file mode 100644 index 0775ad4..0000000 --- a/plugins/network/migrations/alembic.ini +++ /dev/null @@ -1,30 +0,0 @@ -[alembic] -script_location = . -prepend_sys_path = . -file_template = %%(rev)s_%%(slug)s - -[logging] -keys = root - -[loggers] -keys = root - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = INFO -handlers = console -qualname = - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = INFO -formatter = generic - -[formatter_generic] -format = %%(levelname)-5.5s [%%(name)s] %%(message)s diff --git a/plugins/network/migrations/env.py b/plugins/network/migrations/env.py deleted file mode 100644 index 3967a1a..0000000 --- a/plugins/network/migrations/env.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Alembic env.py for the network plugin. - -Thin shim that sets PLUGIN_NAME then delegates to the shared template at -shopdb.plugins.alembic_template, which filters MetaData to only this -plugin's tables and runs Alembic against the Flask app's engine. -""" -import os -os.environ['PLUGIN_NAME'] = 'network' - -from shopdb.plugins.alembic_template import run_migrations - -run_migrations() diff --git a/plugins/network/migrations/script.py.mako b/plugins/network/migrations/script.py.mako deleted file mode 100644 index f230367..0000000 --- a/plugins/network/migrations/script.py.mako +++ /dev/null @@ -1,23 +0,0 @@ -"""${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 = ${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_baseline.py b/plugins/network/migrations/versions/0001_baseline.py deleted file mode 100644 index 2be8b72..0000000 --- a/plugins/network/migrations/versions/0001_baseline.py +++ /dev/null @@ -1,27 +0,0 @@ -"""network plugin: baseline schema - -Creates every table owned by the network plugin per -shopdb.plugins.alembic_template.PLUGIN_TABLE_OWNERS. The table definitions -are derived from the SQLAlchemy models at migration runtime so this stays -in lockstep with the model layer without duplication. - -Revision ID: 0001_baseline_network -Revises: -Create Date: 2026-05-30 - -""" -from shopdb.plugins.alembic_template import create_plugin_tables, drop_plugin_tables - - -revision = '0001_baseline_network' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - create_plugin_tables('network') - - -def downgrade(): - drop_plugin_tables('network') diff --git a/plugins/network/models/network_device.py b/plugins/network/models/network_device.py index da8ac04..c4a8490 100644 --- a/plugins/network/models/network_device.py +++ b/plugins/network/models/network_device.py @@ -1,121 +1,120 @@ -"""Network device plugin models.""" - -from shopdb.extensions import db -from shopdb.core.models.base import 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') - - 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), - index=True, - 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 +"""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') + + 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), + index=True, + 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 index e2332f2..6a52b9f 100644 --- a/plugins/network/models/subnet.py +++ b/plugins/network/models/subnet.py @@ -1,146 +1,145 @@ -"""Subnet and VLAN models for network plugin.""" - -from shopdb.extensions import db -from shopdb.core.models.base import 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 +"""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 index 1ca063d..4694ecd 100644 --- a/plugins/network/plugin.py +++ b/plugins/network/plugin.py @@ -1,217 +1,216 @@ -"""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.extensions import db -from shopdb.core.models import 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.core.models 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, - }, - ] +"""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, + }, + ] diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py index 486b6a2..8d43013 100644 --- a/plugins/notifications/api/routes.py +++ b/plugins/notifications/api/routes.py @@ -4,14 +4,7 @@ from datetime import datetime from flask import Blueprint, request from flask_jwt_extended import jwt_required -from shopdb.extensions import db -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query +from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, employee_connection from ..models import Notification, NotificationType @@ -93,7 +86,7 @@ def list_notifications(): query = query.filter(Notification.isactive == True) # Type filter - if type_id := request.args.get('type_id'): + if type_id := request.args.get('typeid', request.args.get('type_id')): query = query.filter(Notification.notificationtypeid == int(type_id)) # Current filter (active based on dates) @@ -522,11 +515,7 @@ def get_shopfloor_notifications(): # Try to get picture from wjf_employees if n.employeesso and n.employeesso.isdigit(): try: - import pymysql - conn = pymysql.connect( - host='localhost', user='root', password='rootpassword', - database='wjf_employees', cursorclass=pymysql.cursors.DictCursor - ) + conn = employee_connection() with conn.cursor() as cur: cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(n.employeesso),)) emp = cur.fetchone() @@ -555,11 +544,7 @@ def get_shopfloor_notifications(): picture = None if sso.isdigit(): try: - import pymysql - conn = pymysql.connect( - host='localhost', user='root', password='rootpassword', - database='wjf_employees', cursorclass=pymysql.cursors.DictCursor - ) + conn = employee_connection() with conn.cursor() as cur: cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),)) emp = cur.fetchone() @@ -591,11 +576,7 @@ def get_shopfloor_notifications(): picture = None if sso.isdigit(): try: - import pymysql - conn = pymysql.connect( - host='localhost', user='root', password='rootpassword', - database='wjf_employees', cursorclass=pymysql.cursors.DictCursor - ) + conn = employee_connection() with conn.cursor() as cur: cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),)) emp = cur.fetchone() diff --git a/plugins/notifications/migrations/alembic.ini b/plugins/notifications/migrations/alembic.ini deleted file mode 100644 index 0775ad4..0000000 --- a/plugins/notifications/migrations/alembic.ini +++ /dev/null @@ -1,30 +0,0 @@ -[alembic] -script_location = . -prepend_sys_path = . -file_template = %%(rev)s_%%(slug)s - -[logging] -keys = root - -[loggers] -keys = root - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = INFO -handlers = console -qualname = - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = INFO -formatter = generic - -[formatter_generic] -format = %%(levelname)-5.5s [%%(name)s] %%(message)s diff --git a/plugins/notifications/migrations/env.py b/plugins/notifications/migrations/env.py deleted file mode 100644 index d02c111..0000000 --- a/plugins/notifications/migrations/env.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Alembic env.py for the notifications plugin. - -Thin shim that sets PLUGIN_NAME then delegates to the shared template at -shopdb.plugins.alembic_template, which filters MetaData to only this -plugin's tables and runs Alembic against the Flask app's engine. -""" -import os -os.environ['PLUGIN_NAME'] = 'notifications' - -from shopdb.plugins.alembic_template import run_migrations - -run_migrations() diff --git a/plugins/notifications/migrations/script.py.mako b/plugins/notifications/migrations/script.py.mako deleted file mode 100644 index f230367..0000000 --- a/plugins/notifications/migrations/script.py.mako +++ /dev/null @@ -1,23 +0,0 @@ -"""${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 = ${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_baseline.py b/plugins/notifications/migrations/versions/0001_baseline.py deleted file mode 100644 index 6d44b6f..0000000 --- a/plugins/notifications/migrations/versions/0001_baseline.py +++ /dev/null @@ -1,27 +0,0 @@ -"""notifications plugin: baseline schema - -Creates every table owned by the notifications plugin per -shopdb.plugins.alembic_template.PLUGIN_TABLE_OWNERS. The table definitions -are derived from the SQLAlchemy models at migration runtime so this stays -in lockstep with the model layer without duplication. - -Revision ID: 0001_baseline_notifications -Revises: -Create Date: 2026-05-30 - -""" -from shopdb.plugins.alembic_template import create_plugin_tables, drop_plugin_tables - - -revision = '0001_baseline_notifications' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - create_plugin_tables('notifications') - - -def downgrade(): - drop_plugin_tables('notifications') diff --git a/plugins/notifications/models/notification.py b/plugins/notifications/models/notification.py index bcccd38..214bdc5 100644 --- a/plugins/notifications/models/notification.py +++ b/plugins/notifications/models/notification.py @@ -1,157 +1,157 @@ -"""Notifications plugin models - adapted to existing database schema.""" - -from datetime import datetime -from shopdb.extensions 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) - - 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 - } - - -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) - 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) - employeesso = db.Column(db.String(100), nullable=True) - employeename = db.Column(db.String(100), 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.utcnow() - 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.""" - # Map Bootstrap color names to hex colors - color_map = { - 'success': '#04b962', - 'warning': '#ff8800', - 'danger': '#f5365c', - 'info': '#14abef', - 'primary': '#7934f3', - 'secondary': '#94614f', - 'recognition': '#14abef', # Blue for recognition - } - - raw_color = self.notificationtype.typecolor if self.notificationtype else 'info' - # Use mapped color if it's a Bootstrap name, otherwise use as-is (hex) - color = color_map.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef') - - # For recognition notifications, include employee name (or SSO as fallback) in title - title = self.title - if raw_color == 'recognition': - employee_display = self.employeename or self.employeesso - if employee_display: - 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': self.notificationtype.typename if self.notificationtype else None, - 'typecolor': raw_color, - 'linkurl': self.link, - 'ticketnumber': self.ticketnumber, - 'employeename': self.employeename, - 'employeesso': self.employeesso, - } - } +"""Notifications plugin models - adapted to existing database schema.""" + +from datetime import datetime +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) + + 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 + } + + +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) + 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) + employeesso = db.Column(db.String(100), nullable=True) + employeename = db.Column(db.String(100), 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.utcnow() + 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.""" + # Map Bootstrap color names to hex colors + color_map = { + 'success': '#04b962', + 'warning': '#ff8800', + 'danger': '#f5365c', + 'info': '#14abef', + 'primary': '#7934f3', + 'secondary': '#94614f', + 'recognition': '#14abef', # Blue for recognition + } + + raw_color = self.notificationtype.typecolor if self.notificationtype else 'info' + # Use mapped color if it's a Bootstrap name, otherwise use as-is (hex) + color = color_map.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef') + + # For recognition notifications, include employee name (or SSO as fallback) in title + title = self.title + if raw_color == 'recognition': + employee_display = self.employeename or self.employeesso + if employee_display: + 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': self.notificationtype.typename if self.notificationtype else None, + 'typecolor': raw_color, + 'linkurl': self.link, + 'ticketnumber': self.ticketnumber, + 'employeename': self.employeename, + 'employeesso': self.employeesso, + } + } diff --git a/plugins/notifications/plugin.py b/plugins/notifications/plugin.py index 495a91a..cd7ef0a 100644 --- a/plugins/notifications/plugin.py +++ b/plugins/notifications/plugin.py @@ -1,204 +1,204 @@ -"""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.extensions 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.""" - default_types = [ - ('Awareness', 'General awareness notification', '#17a2b8', 'info-circle'), - ('Change', 'Planned change notification', '#ffc107', 'exchange-alt'), - ('Incident', 'Incident or outage notification', '#dc3545', 'exclamation-triangle'), - ('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'wrench'), - ('General', 'General announcement', '#28a745', 'bullhorn'), - ] - - for typename, description, color, icon in default_types: - existing = NotificationType.query.filter_by(typename=typename).first() - if not existing: - t = NotificationType( - typename=typename, - description=description, - color=color, - icon=icon - ) - 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 - - with current_app.app_context(): - now = datetime.utcnow() - - 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, - }, - ] +"""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.""" + default_types = [ + ('Awareness', 'General awareness notification', '#17a2b8', 'info-circle'), + ('Change', 'Planned change notification', '#ffc107', 'exchange-alt'), + ('Incident', 'Incident or outage notification', '#dc3545', 'exclamation-triangle'), + ('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'wrench'), + ('General', 'General announcement', '#28a745', 'bullhorn'), + ] + + for typename, description, color, icon in default_types: + existing = NotificationType.query.filter_by(typename=typename).first() + if not existing: + t = NotificationType( + typename=typename, + description=description, + color=color, + icon=icon + ) + 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 + + with current_app.app_context(): + now = datetime.utcnow() + + 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, + }, + ] diff --git a/plugins/printers/api/__init__.py b/plugins/printers/api/__init__.py index d4bfa10..5226ee0 100644 --- a/plugins/printers/api/__init__.py +++ b/plugins/printers/api/__init__.py @@ -1,9 +1,7 @@ """Printers plugin API.""" -from .routes import printers_bp # Legacy Machine-based API -from .asset_routes import printers_asset_bp # New Asset-based API +from .asset_routes import printers_asset_bp # Asset-based API __all__ = [ - 'printers_bp', # Legacy - 'printers_asset_bp', # New + 'printers_asset_bp', ] diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index ceca6c8..d5f7c91 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -1,607 +1,926 @@ -"""Printers API routes - new Asset-based architecture.""" - -import logging - -from flask import Blueprint, request -from flask_jwt_extended import jwt_required - -from shopdb.extensions import db, cache -from shopdb.core.models import Asset, AssetType, Vendor, Model, Communication, CommunicationType -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query - -from ..models import Printer, PrinterType -from ..services import ZabbixService - -logger = logging.getLogger(__name__) - -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 = PrinterType.query.get(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() -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') - - if PrinterType.query.filter_by(printertype=data['printertype']).first(): - 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') - ) - - db.session.add(t) - db.session.commit() - - return success_response(t.to_dict(), message='Printer type created', http_code=201) - - -# ============================================================================= -# 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) - - # 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 type_id := request.args.get('type_id'): - query = query.filter(Printer.printertypeid == int(type_id)) - - # Vendor filter - if vendor_id := request.args.get('vendor_id'): - query = query.filter(Printer.vendorid == int(vendor_id)) - - # Location filter - if location_id := request.args.get('location_id'): - query = query.filter(Asset.locationid == int(location_id)) - - # Business unit filter - if bu_id := 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('/', methods=['GET']) -@jwt_required(optional=True) -def get_printer(printer_id: int): - """Get a single printer with full details.""" - printer = Printer.query.get(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] - - 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() -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'), - 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) - - 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() -def update_printer(printer_id: int): - """Update printer (both Asset and Printer records).""" - printer = Printer.query.get(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 - asset_fields = ['assetnumber', 'name', 'serialnumber', '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]) - - 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() -def delete_printer(printer_id: int): - """Delete (soft delete) printer.""" - printer = Printer.query.get(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 = Printer.query.get(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: - # Return empty supplies if Zabbix not available (fail gracefully) - return success_response({ - 'ipaddress': comm.ipaddress, - 'supplies': [] - }) - - supplies = service.getsuppliesbyip(comm.ipaddress) - - return success_response({ - 'ipaddress': comm.ipaddress, - 'supplies': supplies or [] - }) - - -# ============================================================================= -# Low Supplies -# ============================================================================= - -def _get_low_supplies_data(): - """Build low supplies data (cached for 10 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}} - - # All active printers with an IP address - printers = ( - db.session.query(Printer, Asset, Communication) - .join(Asset, Asset.assetid == Printer.assetid) - .join(Communication, Communication.assetid == Asset.assetid) - .filter(Asset.isactive == True) - .filter(Communication.ipaddress.isnot(None)) - .filter(Communication.ipaddress != '') - .all() - ) - - # Dedupe by printer id (may have multiple comms) - seen = set() - unique_printers = [] - for printer, asset, comm in printers: - if printer.printerid not in seen: - seen.add(printer.printerid) - unique_printers.append((printer, asset, comm)) - - results = [] - total_checked = 0 - - for printer, asset, comm in unique_printers: - supplies = service.getsuppliesbyip_cached(comm.ipaddress) - if supplies is None: - continue - - total_checked += 1 - - # Annotate each supply with status - annotated = [] - has_low = False - for s in supplies: - level = s.get('level', 0) - if level <= 5: - status = 'critical' - has_low = True - elif level <= 10: - status = 'low' - has_low = True - else: - status = 'ok' - annotated.append({ - 'name': s.get('name', 'Unknown'), - 'level': level, - 'status': status - }) - - if has_low: - # Get location name - location_name = None - if asset.locationid: - from shopdb.core.models import Location - loc = Location.query.get(asset.locationid) - if loc: - location_name = loc.location - - results.append({ - 'printerid': printer.printerid, - 'printername': asset.name or printer.hostname or '', - 'assetnumber': asset.assetnumber or '', - 'ipaddress': comm.ipaddress, - '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=600) - 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) - - -# ============================================================================= -# 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], - }) +"""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, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query + +from ..models import Printer, PrinterType, ModelSupply +from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS +from ..services import ( + ZabbixService, + classifysupply, + derivesupplytype, + derivecolor, + lookupsupplies, +) + +logger = logging.getLogger(__name__) + +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 = PrinterType.query.get(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() +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') + + if PrinterType.query.filter_by(printertype=data['printertype']).first(): + 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') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Printer type created', http_code=201) + + +# ============================================================================= +# 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) + + # 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('/', methods=['GET']) +@jwt_required(optional=True) +def get_printer(printer_id: int): + """Get a single printer with full details.""" + printer = Printer.query.get(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] + + 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() +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) + + 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() +def update_printer(printer_id: int): + """Update printer (both Asset and Printer records).""" + printer = Printer.query.get(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 + + 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() +def delete_printer(printer_id: int): + """Delete (soft delete) printer.""" + printer = Printer.query.get(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 = Printer.query.get(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 = Location.query.get(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() +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 = Model.query.get(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() +def create_model_supply(modelnumberid: int): + """Add a supply to a model.""" + model = Model.query.get(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() +def update_model_supply(modelsupplyid: int): + """Update a model supply.""" + supply = ModelSupply.query.get(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() +def delete_model_supply(modelsupplyid: int): + """Delete a model supply.""" + supply = ModelSupply.query.get(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/api/routes.py b/plugins/printers/api/routes.py deleted file mode 100644 index 69c0261..0000000 --- a/plugins/printers/api/routes.py +++ /dev/null @@ -1,273 +0,0 @@ -"""Printers API routes.""" - -from flask import Blueprint, request -from flask_jwt_extended import jwt_required - -from shopdb.extensions import db -from shopdb.utils.responses import success_response, error_response, paginated_response, ErrorCodes -from shopdb.utils.pagination import get_pagination_params, paginate_query -from shopdb.core.models.machine import Machine, MachineType -from shopdb.core.models.communication import Communication, CommunicationType -from shopdb.core.models import AuditLog - -from ..models import PrinterData -from ..services import ZabbixService - -printers_bp = Blueprint('printers', __name__) - - -@printers_bp.route('/', methods=['GET']) -@jwt_required(optional=True) -def list_printers(): - """List all printers.""" - page, per_page = get_pagination_params(request) - - # Get printer machine types - printer_types = MachineType.query.filter_by(category='Printer').all() - printer_type_ids = [pt.machinetypeid for pt in printer_types] - - query = Machine.query.filter( - Machine.machinetypeid.in_(printer_type_ids), - Machine.isactive == True - ) - - # Filters - if location_id := request.args.get('location', type=int): - query = query.filter(Machine.locationid == location_id) - - if search := request.args.get('search'): - query = query.filter( - db.or_( - Machine.machinenumber.ilike(f'%{search}%'), - Machine.hostname.ilike(f'%{search}%'), - Machine.alias.ilike(f'%{search}%') - ) - ) - - query = query.order_by(Machine.machinenumber) - items, total = paginate_query(query, page, per_page) - - printers = [] - for machine in items: - printer_data = { - 'machineid': machine.machineid, - 'machinenumber': machine.machinenumber, - 'hostname': machine.hostname, - 'alias': machine.alias, - 'serialnumber': machine.serialnumber, - 'location': machine.location.locationname if machine.location else None, - 'vendor': machine.vendor.vendor if machine.vendor else None, - 'model': machine.model.modelnumber if machine.model else None, - 'status': machine.status.status if machine.status else None, - } - - # Add printer-specific data - if machine.printerdata: - pd = machine.printerdata - printer_data['printerdata'] = { - 'windowsname': pd.windowsname, - 'sharename': pd.sharename, - 'iscsf': pd.iscsf, - 'pin': pd.pin, - } - - # Get IP from communications - primary_comm = next((c for c in machine.communications if c.isprimary), None) - if not primary_comm and machine.communications: - primary_comm = machine.communications[0] - printer_data['ipaddress'] = primary_comm.ipaddress if primary_comm else None - - printers.append(printer_data) - - return paginated_response(printers, page, per_page, total) - - -@printers_bp.route('/', methods=['GET']) -@jwt_required(optional=True) -def get_printer(machine_id: int): - """Get a single printer with details.""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) - - data = machine.to_dict() - data['machinetype'] = machine.machinetype.to_dict() if machine.machinetype else None - data['vendor'] = machine.vendor.to_dict() if machine.vendor else None - data['model'] = machine.model.to_dict() if machine.model else None - data['location'] = machine.location.to_dict() if machine.location else None - data['status'] = machine.status.to_dict() if machine.status else None - data['communications'] = [c.to_dict() for c in machine.communications] - - # Add printer-specific data - if machine.printerdata: - pd = machine.printerdata - data['printerdata'] = { - 'id': pd.id, - 'windowsname': pd.windowsname, - 'sharename': pd.sharename, - 'iscsf': pd.iscsf, - 'installpath': pd.installpath, - 'pin': pd.pin, - } - - return success_response(data) - - -@printers_bp.route('//printerdata', methods=['PUT']) -@jwt_required() -def update_printer_data(machine_id: int): - """Update printer-specific data.""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) - - data = request.get_json() - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - # Get or create printer data - pd = machine.printerdata - if not pd: - pd = PrinterData(machineid=machine_id) - db.session.add(pd) - - # Track changes for audit log - changes = {} - for key in ['windowsname', 'sharename', 'iscsf', 'installpath', 'pin']: - if key in data: - old_val = getattr(pd, key, None) - new_val = data[key] - if old_val != new_val: - changes[key] = {'old': old_val, 'new': new_val} - setattr(pd, key, data[key]) - - # Audit log if there were changes - if changes: - AuditLog.log('updated', 'Printer', entityid=machine_id, - entityname=machine.machinenumber or machine.hostname, changes=changes) - - db.session.commit() - - return success_response({ - 'id': pd.id, - 'windowsname': pd.windowsname, - 'sharename': pd.sharename, - 'iscsf': pd.iscsf, - 'installpath': pd.installpath, - 'pin': pd.pin, - }, message='Printer data updated') - - -@printers_bp.route('//communication', methods=['PUT']) -@jwt_required() -def update_printer_communication(machine_id: int): - """Update printer communication (IP address).""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) - - data = request.get_json() - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - # Get or create IP communication type - ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() - if not ip_comtype: - ip_comtype = CommunicationType(comtype='IP', description='IP Network') - db.session.add(ip_comtype) - db.session.flush() - - # Find existing primary communication or create new one - comm = next((c for c in machine.communications if c.isprimary), None) - if not comm: - comm = next((c for c in machine.communications if c.comtypeid == ip_comtype.comtypeid), None) - if not comm: - comm = Communication(machineid=machine_id, comtypeid=ip_comtype.comtypeid) - db.session.add(comm) - - # Track changes for audit log - changes = {} - - # Update fields - if 'ipaddress' in data: - if comm.ipaddress != data['ipaddress']: - changes['ipaddress'] = {'old': comm.ipaddress, 'new': data['ipaddress']} - comm.ipaddress = data['ipaddress'] - if 'isprimary' in data: - if comm.isprimary != data['isprimary']: - changes['isprimary'] = {'old': comm.isprimary, 'new': data['isprimary']} - comm.isprimary = data['isprimary'] - if 'macaddress' in data: - if comm.macaddress != data['macaddress']: - changes['macaddress'] = {'old': comm.macaddress, 'new': data['macaddress']} - comm.macaddress = data['macaddress'] - - # Audit log if there were changes - if changes: - AuditLog.log('updated', 'Printer', entityid=machine_id, - entityname=machine.machinenumber or machine.hostname, changes=changes) - - db.session.commit() - - return success_response({ - 'communicationid': comm.communicationid, - 'ipaddress': comm.ipaddress, - 'isprimary': comm.isprimary, - }, message='Communication updated') - - -@printers_bp.route('//supplies', methods=['GET']) -@jwt_required(optional=True) -def get_printer_supplies(machine_id: int): - """Get supply levels from Zabbix (real-time lookup).""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) - - # Get IP address - primary_comm = next((c for c in machine.communications if c.isprimary), None) - if not primary_comm and machine.communications: - primary_comm = machine.communications[0] - - if not primary_comm or not primary_comm.ipaddress: - return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address') - - service = ZabbixService() - if not service.isconfigured or not service.isreachable: - # Return empty supplies if Zabbix not available (fail gracefully) - return success_response({ - 'ipaddress': primary_comm.ipaddress, - 'supplies': [] - }) - - supplies = service.getsuppliesbyip(primary_comm.ipaddress) - - return success_response({ - 'ipaddress': primary_comm.ipaddress, - 'supplies': supplies or [] - }) - - -@printers_bp.route('/dashboard/summary', methods=['GET']) -@jwt_required(optional=True) -def dashboard_summary(): - """Get printer summary for dashboard.""" - printer_types = MachineType.query.filter_by(category='Printer').all() - printer_type_ids = [pt.machinetypeid for pt in printer_types] - - total = Machine.query.filter( - Machine.machinetypeid.in_(printer_type_ids), - Machine.isactive == True - ).count() - - return success_response({ - 'totalprinters': total, - 'total': total, - 'online': total, # Placeholder - would need Zabbix integration for real status - 'lowsupplies': 0, - 'criticalsupplies': 0 - }) diff --git a/plugins/printers/migrations/__init__.py b/plugins/printers/migrations/__init__.py deleted file mode 100644 index bfb65cf..0000000 --- a/plugins/printers/migrations/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Printers plugin migrations.""" diff --git a/plugins/printers/migrations/alembic.ini b/plugins/printers/migrations/alembic.ini deleted file mode 100644 index 0775ad4..0000000 --- a/plugins/printers/migrations/alembic.ini +++ /dev/null @@ -1,30 +0,0 @@ -[alembic] -script_location = . -prepend_sys_path = . -file_template = %%(rev)s_%%(slug)s - -[logging] -keys = root - -[loggers] -keys = root - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = INFO -handlers = console -qualname = - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = INFO -formatter = generic - -[formatter_generic] -format = %%(levelname)-5.5s [%%(name)s] %%(message)s diff --git a/plugins/printers/migrations/env.py b/plugins/printers/migrations/env.py deleted file mode 100644 index 0cec79d..0000000 --- a/plugins/printers/migrations/env.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Alembic env.py for the printers plugin. - -Thin shim that sets PLUGIN_NAME then delegates to the shared template at -shopdb.plugins.alembic_template, which filters MetaData to only this -plugin's tables and runs Alembic against the Flask app's engine. -""" -import os -os.environ['PLUGIN_NAME'] = 'printers' - -from shopdb.plugins.alembic_template import run_migrations - -run_migrations() diff --git a/plugins/printers/migrations/script.py.mako b/plugins/printers/migrations/script.py.mako deleted file mode 100644 index f230367..0000000 --- a/plugins/printers/migrations/script.py.mako +++ /dev/null @@ -1,23 +0,0 @@ -"""${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 = ${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_baseline.py b/plugins/printers/migrations/versions/0001_baseline.py deleted file mode 100644 index 8247960..0000000 --- a/plugins/printers/migrations/versions/0001_baseline.py +++ /dev/null @@ -1,27 +0,0 @@ -"""printers plugin: baseline schema - -Creates every table owned by the printers plugin per -shopdb.plugins.alembic_template.PLUGIN_TABLE_OWNERS. The table definitions -are derived from the SQLAlchemy models at migration runtime so this stays -in lockstep with the model layer without duplication. - -Revision ID: 0001_baseline_printers -Revises: -Create Date: 2026-05-30 - -""" -from shopdb.plugins.alembic_template import create_plugin_tables, drop_plugin_tables - - -revision = '0001_baseline_printers' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - create_plugin_tables('printers') - - -def downgrade(): - drop_plugin_tables('printers') diff --git a/plugins/printers/models/__init__.py b/plugins/printers/models/__init__.py index 93b2333..0434f25 100644 --- a/plugins/printers/models/__init__.py +++ b/plugins/printers/models/__init__.py @@ -1,10 +1,18 @@ """Printers plugin models.""" -from .printer_extension import PrinterData # Legacy model for Machine-based architecture -from .printer import Printer, PrinterType # New Asset-based models +from .printer import Printer, PrinterType # Asset-based models +from .model_supply import ( # data-driven model -> toner/drum/waste mapping + ModelSupply, + SUPPLY_TYPES, + SUPPLY_COLORS, + CAPACITY_TIERS, +) __all__ = [ - 'PrinterData', # Legacy - 'Printer', # New - 'PrinterType', # New + 'Printer', + 'PrinterType', + '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 0000000..05d7575 --- /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 index 8698e9c..beab8d8 100644 --- a/plugins/printers/models/printer.py +++ b/plugins/printers/models/printer.py @@ -1,122 +1,121 @@ -"""Printer plugin models - new Asset-based architecture.""" - -from shopdb.extensions import db -from shopdb.core.models.base import 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') - - 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), - index=True, - 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 - - return result +"""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') + + 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), + index=True, + 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 + + return result diff --git a/plugins/printers/models/printer_extension.py b/plugins/printers/models/printer_extension.py deleted file mode 100644 index 12144b6..0000000 --- a/plugins/printers/models/printer_extension.py +++ /dev/null @@ -1,58 +0,0 @@ -"""PrinterData model - printer-specific fields linked to machines.""" - -from shopdb.extensions import db -from shopdb.core.models.base import BaseModel - - -class PrinterData(BaseModel): - """ - Printer-specific data linked to Machine table. - - Printers are stored in the machines table (machinetype.category = 'Printer'). - This table only holds printer-specific fields not in machines. - - IP address is stored in the communications table. - Zabbix data is queried in real-time via API (not cached here). - """ - __tablename__ = 'printerdata' - - id = db.Column(db.Integer, primary_key=True) - - # Link to machine - machineid = db.Column( - db.Integer, - db.ForeignKey('machines.machineid', ondelete='CASCADE'), - unique=True, - nullable=False, - index=True - ) - - # 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)) - - # Relationship - machine = db.relationship( - 'Machine', - backref=db.backref('printerdata', uselist=False, lazy='joined') - ) - - __table_args__ = ( - db.Index('idx_printerdata_windowsname', 'windowsname'), - ) - - def __repr__(self): - return f"" diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py index 5cc9c06..d647497 100644 --- a/plugins/printers/plugin.py +++ b/plugins/printers/plugin.py @@ -9,12 +9,10 @@ from flask import Flask, Blueprint import click from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.extensions import db -from shopdb.core.models.machine import MachineType -from shopdb.core.models import AssetType +from shopdb.api import db, AssetType -from .models import PrinterData, Printer, PrinterType -from .api import printers_bp, printers_asset_bp +from .models import Printer, PrinterType, ModelSupply +from .api import printers_asset_bp from .services import ZabbixService logger = logging.getLogger(__name__) @@ -74,9 +72,9 @@ class PrintersPlugin(BasePlugin): def get_models(self) -> List[Type]: """Return list of SQLAlchemy model classes.""" return [ - PrinterData, # Legacy Machine-based - Printer, # New Asset-based - PrinterType, # New printer type classification + Printer, # Asset-based + PrinterType, # printer type classification + ModelSupply, # model -> toner/drum/waste part numbers ] def get_services(self) -> Dict[str, Type]: @@ -97,9 +95,6 @@ class PrintersPlugin(BasePlugin): app.config.setdefault('ZABBIX_URL', '') app.config.setdefault('ZABBIX_TOKEN', '') - # Register legacy blueprint for backward compatibility - app.register_blueprint(printers_bp, url_prefix='/api/printers/legacy') - logger.info(f"Printers plugin initialized (v{self.meta.version})") def on_install(self, app: Flask) -> None: @@ -107,7 +102,6 @@ class PrintersPlugin(BasePlugin): with app.app_context(): self._ensure_asset_type() self._ensure_printer_types() - self._ensure_legacy_machine_types() logger.info("Printers plugin installed") def _ensure_asset_type(self) -> None: @@ -131,6 +125,7 @@ class PrintersPlugin(BasePlugin): ('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'), @@ -151,30 +146,6 @@ class PrintersPlugin(BasePlugin): db.session.commit() - def _ensure_legacy_machine_types(self) -> None: - """Ensure basic printer machine types exist (legacy architecture).""" - printertypes = [ - ('Laser Printer', 'Printer', 'Standard laser printer'), - ('Inkjet Printer', 'Printer', 'Inkjet printer'), - ('Label Printer', 'Printer', 'Label/barcode printer'), - ('Multifunction Printer', 'Printer', 'MFP with scan/copy/fax'), - ('Plotter', 'Printer', 'Large format plotter'), - ] - - for name, category, description in printertypes: - existing = MachineType.query.filter_by(machinetype=name).first() - if not existing: - mt = MachineType( - machinetype=name, - category=category, - description=description, - icon='printer' - ) - 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("Printers plugin uninstalled") @@ -209,6 +180,19 @@ class PrintersPlugin(BasePlugin): 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]: diff --git a/plugins/printers/services/__init__.py b/plugins/printers/services/__init__.py index 7791543..6a78fa9 100644 --- a/plugins/printers/services/__init__.py +++ b/plugins/printers/services/__init__.py @@ -1,5 +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'] +__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 0000000..82e69b7 --- /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: + # machinetypeid 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 0000000..178b109 --- /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 index a54563c..ddd8e60 100644 --- a/plugins/printers/services/zabbix_service.py +++ b/plugins/printers/services/zabbix_service.py @@ -1,4 +1,22 @@ -"""Zabbix service for real-time printer supply lookups.""" +"""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 @@ -6,59 +24,65 @@ from typing import Dict, List, Optional import requests from flask import current_app -from shopdb.extensions import cache +from shopdb.api import cache logger = logging.getLogger(__name__) class ZabbixService: - """ - Zabbix API service for real-time printer supply lookups. + """Zabbix API client for printer supply and ping lookups.""" - Queries Zabbix by IP address to get current supply levels. - Use getsuppliesbyip_cached() for cached lookups or - getsuppliesbyip() for live data. + CACHE_TTL = 300 # 5 min, matches the classic Application cache + REACHABLE_CHECK_TTL = 60 - Configuration: - ZABBIX_ENABLED: Set to True to enable Zabbix integration (default: False) - ZABBIX_URL: Zabbix API URL (e.g., http://zabbix.example.com:8080) - ZABBIX_TOKEN: Zabbix API authentication token - """ + # 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) - CACHE_TTL = 600 # 10 minutes - REACHABLE_CHECK_TTL = 60 # Check reachability every 60 seconds + # 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 - self._enabled = None + + # -- configuration ------------------------------------------------------- @property def isenabled(self) -> bool: - """Check if Zabbix integration is enabled.""" - # Check database setting first, fall back to env var - from shopdb.core.models import Setting + """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) - # Fall back to env var for backwards compatibility return current_app.config.get('ZABBIX_ENABLED', False) @property def isconfigured(self) -> bool: - """Check if Zabbix is enabled and configured.""" + """Enabled, and a URL plus token are present.""" if not self.isenabled: return False - # Check database settings first, fall back to env vars - from shopdb.core.models import Setting + 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: - """Check if Zabbix is reachable (cached for 60 seconds).""" - if not self.isenabled or not self.isconfigured: + """Cheap connectivity probe, cached for 60s.""" + if not self.isconfigured: return False cache_key = 'zabbix_reachable' @@ -66,138 +90,165 @@ class ZabbixService: if cached is not None: return cached - # Quick connectivity check with 500ms timeout try: - response = requests.get( - f"{self._url}/api_jsonrpc.php", - timeout=0.5 - ) - reachable = response.status_code in (200, 401, 403, 405) + 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(f"Zabbix reachability check: {reachable}") + logger.debug("Zabbix reachability: %s", reachable) return reachable - def _apicall(self, method: str, params: Dict) -> Optional[Dict]: - """Make a Zabbix API call.""" + # -- 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, - 'auth': self._token, - 'id': 1 + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1, + } + headers = { + "Content-Type": "application/json-rpc", + "Authorization": f"Bearer {self._token}", } try: response = requests.post( - f"{self._url}/api_jsonrpc.php", + self.endpoint, json=payload, - headers={'Content-Type': 'application/json'}, - timeout=0.5 # 500ms timeout - fail fast if Zabbix is slow/unreachable + headers=headers, + timeout=self.API_TIMEOUT, ) response.raise_for_status() data = response.json() - - if 'error' in data: - logger.error(f"Zabbix API error: {data['error']}") - return None - - return data.get('result') - - except requests.RequestException as e: - logger.error(f"Zabbix API request failed: {e}") + except (requests.RequestException, ValueError) as exc: + logger.error("Zabbix %s call failed: %s", method, exc) return None - def gethostbyip(self, ip: str) -> Optional[Dict]: - """Find a Zabbix host by IP address.""" - result = self._apicall('host.get', { - 'output': ['hostid', 'host', 'name'], - 'filter': {'ip': ip}, - 'selectInterfaces': ['ip'] - }) + 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] if result else None + return result[0].get("hostid") return None - def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]: - """ - Get printer supply levels by IP address. + 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" - Returns list of supplies with name and level percentage. + 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. """ - # Find host by IP - host = self.gethostbyip(ip) - if not host: - logger.debug(f"No Zabbix host found for IP {ip}") + hostid = self.gethostidbyip(ip) + if not hostid: + logger.debug("No Zabbix host for IP %s", ip) return None - hostid = host['hostid'] - - # Get supply-related items - items = self._apicall('item.get', { - 'output': ['itemid', 'name', 'lastvalue', 'key_'], - 'hostids': hostid, - 'search': { - 'key_': 'supply' # Common key pattern for printer supplies - }, - 'searchWildcardsEnabled': True + 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: - # Try alternate patterns - items = self._apicall('item.get', { - 'output': ['itemid', 'name', 'lastvalue', 'key_'], - 'hostids': hostid, - 'search': { - 'name': 'toner' - }, - 'searchWildcardsEnabled': 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))) + level = int(float(item.get("lastvalue", 0))) except (ValueError, TypeError): level = 0 - supplies.append({ - 'name': item.get('name', 'Unknown'), - 'level': level, - 'itemid': item.get('itemid'), - 'key': item.get('key_'), + "name": item.get("name", "Unknown"), + "level": level, + "color": self._extract_color(item), + "itemid": item.get("itemid"), }) - return supplies - def gethostid(self, ip: str) -> Optional[str]: - """Get Zabbix host ID for an IP address.""" - host = self.gethostbyip(ip) - return host['hostid'] if host else None + 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]]: - """Get printer supply levels with caching (10-minute TTL).""" - cache_key = f'zabbix_supplies_{ip}' + """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): - """Clear cached supply data for one IP or all.""" + """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(f"zabbix_supplies_{ip}") + cache.delete("printers_low_supplies") + cache.delete("zabbix_reachable") diff --git a/plugins/usb/api/routes.py b/plugins/usb/api/routes.py index a990ca5..d17a664 100644 --- a/plugins/usb/api/routes.py +++ b/plugins/usb/api/routes.py @@ -4,15 +4,7 @@ from flask import Blueprint, request from flask_jwt_extended import jwt_required, get_jwt_identity from datetime import datetime -from shopdb.extensions import db -from shopdb.core.models import 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.api import db, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from ..models import USBDevice, USBDeviceType, USBCheckout diff --git a/plugins/usb/migrations/alembic.ini b/plugins/usb/migrations/alembic.ini deleted file mode 100644 index 0775ad4..0000000 --- a/plugins/usb/migrations/alembic.ini +++ /dev/null @@ -1,30 +0,0 @@ -[alembic] -script_location = . -prepend_sys_path = . -file_template = %%(rev)s_%%(slug)s - -[logging] -keys = root - -[loggers] -keys = root - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = INFO -handlers = console -qualname = - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = INFO -formatter = generic - -[formatter_generic] -format = %%(levelname)-5.5s [%%(name)s] %%(message)s diff --git a/plugins/usb/migrations/env.py b/plugins/usb/migrations/env.py deleted file mode 100644 index 7599521..0000000 --- a/plugins/usb/migrations/env.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Alembic env.py for the usb plugin. - -Thin shim that sets PLUGIN_NAME then delegates to the shared template at -shopdb.plugins.alembic_template, which filters MetaData to only this -plugin's tables and runs Alembic against the Flask app's engine. -""" -import os -os.environ['PLUGIN_NAME'] = 'usb' - -from shopdb.plugins.alembic_template import run_migrations - -run_migrations() diff --git a/plugins/usb/migrations/script.py.mako b/plugins/usb/migrations/script.py.mako deleted file mode 100644 index f230367..0000000 --- a/plugins/usb/migrations/script.py.mako +++ /dev/null @@ -1,23 +0,0 @@ -"""${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 = ${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_baseline.py b/plugins/usb/migrations/versions/0001_baseline.py deleted file mode 100644 index 6200b1f..0000000 --- a/plugins/usb/migrations/versions/0001_baseline.py +++ /dev/null @@ -1,27 +0,0 @@ -"""usb plugin: baseline schema - -Creates every table owned by the usb plugin per -shopdb.plugins.alembic_template.PLUGIN_TABLE_OWNERS. The table definitions -are derived from the SQLAlchemy models at migration runtime so this stays -in lockstep with the model layer without duplication. - -Revision ID: 0001_baseline_usb -Revises: -Create Date: 2026-05-30 - -""" -from shopdb.plugins.alembic_template import create_plugin_tables, drop_plugin_tables - - -revision = '0001_baseline_usb' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - create_plugin_tables('usb') - - -def downgrade(): - drop_plugin_tables('usb') diff --git a/plugins/usb/models/usb_device.py b/plugins/usb/models/usb_device.py index ab43681..f37490b 100644 --- a/plugins/usb/models/usb_device.py +++ b/plugins/usb/models/usb_device.py @@ -1,167 +1,166 @@ -"""USB device plugin models.""" - -from datetime import datetime -from shopdb.extensions import db -from shopdb.core.models.base import BaseModel, AuditMixin - - -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_serial', 'serialnumber'), - 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 - ) - - # Legacy reference to machines table (kept for backward compatibility) - machineid = db.Column(db.Integer, nullable=False) - - # 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=datetime.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.utcnow() - 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 +"""USB device plugin models.""" + +from datetime import datetime +from shopdb.api import db, BaseModel, AuditMixin + + +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_serial', 'serialnumber'), + 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 + ) + + # Legacy reference to machines table (kept for backward compatibility) + machineid = db.Column(db.Integer, nullable=False) + + # 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=datetime.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.utcnow() + 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 index 0952a71..a1ed234 100644 --- a/plugins/usb/plugin.py +++ b/plugins/usb/plugin.py @@ -8,7 +8,7 @@ from typing import List, Dict, Optional, Type from flask import Flask, Blueprint from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.extensions import db +from shopdb.api import db from .models import USBDevice, USBDeviceType, USBCheckout from .api import usb_bp diff --git a/shopdb/__init__.py b/shopdb/__init__.py index cc622c3..71b1a77 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -12,7 +12,13 @@ from .plugins import plugin_manager # 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. -__contract_version__ = '0.2.0' +# 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. +__contract_version__ = '0.4.0' def create_app(config_name: str = None) -> Flask: @@ -41,6 +47,14 @@ def create_app(config_name: str = None) -> Flask: # 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) @@ -79,10 +93,8 @@ def create_app(config_name: str = None) -> Flask: CORE_BLUEPRINT_NAMES = ( 'auth', 'assets', - 'machines', 'machinetypes', - 'pctypes', - 'statuses', + 'plugins', 'vendors', 'models', 'businessunits', diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py index 6439f09..0a03eb9 100644 --- a/shopdb/api/__init__.py +++ b/shopdb/api/__init__.py @@ -14,7 +14,47 @@ Setting helpers are exposed via BasePlugin instance methods from typing import Any, Dict, Optional -from shopdb.core.models import AuditLog +# -- 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, +) + +# 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 + +# Legacy employee directory lookup (read-only) used by notifications +from shopdb.utils.employee_db import employee_connection def audit_log( @@ -155,4 +195,37 @@ def resolve_asset_position(asset) -> Optional[Dict[str, Any]]: return None -__all__ = ['audit_log', 'resolve_asset_position'] +__all__ = [ + # Helpers + 'audit_log', + 'resolve_asset_position', + # Infrastructure + 'db', + 'cache', + # Model bases + 'BaseModel', + 'AuditMixin', + # Core models + 'Asset', + 'AssetType', + 'AssetStatus', + 'Vendor', + 'Model', + 'Communication', + 'CommunicationType', + 'Location', + 'Setting', + 'AuditLog', + 'Application', + 'AppVersion', + 'OperatingSystem', + # Response + pagination helpers + 'success_response', + 'error_response', + 'paginated_response', + 'ErrorCodes', + 'get_pagination_params', + 'paginate_query', + # Legacy employee directory + 'employee_connection', +] diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py index 47a0603..1ee37de 100644 --- a/shopdb/cli/__init__.py +++ b/shopdb/cli/__init__.py @@ -42,7 +42,7 @@ def seed_cli(): def seed_reference_data(): """Seed reference data (machine types, statuses, etc.).""" from shopdb.extensions import db - from shopdb.core.models import MachineType, MachineStatus, OperatingSystem + from shopdb.core.models import MachineType, OperatingSystem, AssetStatus, LocationType from shopdb.core.models.relationship import RelationshipType # Machine types @@ -67,20 +67,31 @@ def seed_reference_data(): mt = MachineType(**mt_data) db.session.add(mt) - # Machine statuses - statuses = [ + # Asset statuses (canonical set - the asset model is the contract) + asset_statuses = [ {'status': 'In Use', 'description': 'Currently in use', 'color': '#28a745'}, - {'status': 'Spare', 'description': 'Available as spare', 'color': '#17a2b8'}, + {'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': 'In Repair', 'description': 'Currently being repaired', 'color': '#ffc107'}, - {'status': 'Pending', 'description': 'Pending installation', 'color': '#007bff'}, + {'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 statuses: - existing = MachineStatus.query.filter_by(status=s_data['status']).first() + for s_data in asset_statuses: + existing = AssetStatus.query.filter_by(status=s_data['status']).first() if not existing: - s = MachineStatus(**s_data) - db.session.add(s) + 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)) # Operating systems os_list = [ @@ -202,153 +213,9 @@ 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 = [ - # 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' - }, - # 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' - }, - ] + defaults = build_default_settings() created = 0 for d in defaults: diff --git a/shopdb/config.py b/shopdb/config.py index 27cce33..708afbc 100644 --- a/shopdb/config.py +++ b/shopdb/config.py @@ -53,9 +53,22 @@ class Config: 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') + CACHE_TYPE = 'SimpleCache' CACHE_DEFAULT_TIMEOUT = 600 diff --git a/shopdb/core/api/__init__.py b/shopdb/core/api/__init__.py index b6da533..f6d9385 100644 --- a/shopdb/core/api/__init__.py +++ b/shopdb/core/api/__init__.py @@ -2,10 +2,8 @@ from .auth import auth_bp from .assets import assets_bp -from .machines import machines_bp from .machinetypes import machinetypes_bp -from .pctypes import pctypes_bp -from .statuses import statuses_bp +from .plugins import plugins_bp from .vendors import vendors_bp from .models import models_bp from .businessunits import businessunits_bp @@ -26,10 +24,8 @@ from .users import users_bp __all__ = [ 'auth_bp', 'assets_bp', - 'machines_bp', 'machinetypes_bp', - 'pctypes_bp', - 'statuses_bp', + 'plugins_bp', 'vendors_bp', 'models_bp', 'businessunits_bp', diff --git a/shopdb/core/api/applications.py b/shopdb/core/api/applications.py index 93bdaf2..5b429ac 100644 --- a/shopdb/core/api/applications.py +++ b/shopdb/core/api/applications.py @@ -1,446 +1,516 @@ -"""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, AppOwner, SupportTeam, InstalledApp, Machine, AuditLog -) -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query - -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) - - 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: - app_dict = app.to_dict() - if app.supportteam: - app_dict['supportteam'] = { - 'supportteamid': app.supportteam.supportteamid, - 'teamname': app.supportteam.teamname, - 'teamurl': app.supportteam.teamurl, - 'owner': { - 'appownerid': app.supportteam.owner.appownerid, - 'appowner': app.supportteam.owner.appowner, - 'sso': app.supportteam.owner.sso - } if app.supportteam.owner else None - } - else: - app_dict['supportteam'] = None - app_dict['installedcount'] = app.installed_on.filter_by(isactive=True).count() - 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 = Application.query.get(app_id) - - if not app: - return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) - - data = app.to_dict() - if app.supportteam: - data['supportteam'] = { - 'supportteamid': app.supportteam.supportteamid, - 'teamname': app.supportteam.teamname, - 'teamurl': app.supportteam.teamurl, - 'owner': { - 'appownerid': app.supportteam.owner.appownerid, - 'appowner': app.supportteam.owner.appowner, - 'sso': app.supportteam.owner.sso - } if app.supportteam.owner else None - } - else: - data['supportteam'] = None - data['versions'] = [v.to_dict() for v in app.versions.filter_by(isactive=True).order_by(AppVersion.version.desc()).all()] - data['installedcount'] = app.installed_on.filter_by(isactive=True).count() - - return success_response(data) - - -@applications_bp.route('', methods=['POST']) -@jwt_required() -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), - image=data.get('image') - ) - - db.session.add(app) - 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() -def update_application(app_id: int): - """Update an application.""" - app = Application.query.get(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', '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) - - db.session.commit() - return success_response(app.to_dict(), message='Application updated') - - -@applications_bp.route('/', methods=['DELETE']) -@jwt_required() -def delete_application(app_id: int): - """Delete (deactivate) an application.""" - app = Application.query.get(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 = Application.query.get(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() -def create_version(app_id: int): - """Create a new version for an application.""" - app = Application.query.get(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) - db.session.commit() - - return success_response(version.to_dict(), message='Version created', http_code=201) - - -# ---- Machines with this app installed ---- - -@applications_bp.route('//installed', methods=['GET']) -@jwt_required(optional=True) -def list_installed_machines(app_id: int): - """List all machines that have this application installed.""" - app = Application.query.get(app_id) - if not app: - return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) - - installed = app.installed_on.filter_by(isactive=True).all() - data = [] - for i in installed: - item = i.to_dict() - if i.machine: - item['machine'] = { - 'machineid': i.machine.machineid, - 'machinenumber': i.machine.machinenumber, - 'alias': i.machine.alias, - 'hostname': i.machine.hostname - } - data.append(item) - - return success_response(data) - - -# ---- Installed Apps (per machine) ---- - -@applications_bp.route('/machines/', methods=['GET']) -@jwt_required(optional=True) -def list_machine_applications(machine_id: int): - """List all applications installed on a machine.""" - machine = Machine.query.get(machine_id) - if not machine: - return error_response(ErrorCodes.NOT_FOUND, 'Machine not found', http_code=404) - - installed = machine.installedapps.filter_by(isactive=True).all() - return success_response([i.to_dict() for i in installed]) - - -@applications_bp.route('/machines/', methods=['POST']) -@jwt_required() -def install_application(machine_id: int): - """Install an application on a machine.""" - machine = Machine.query.get(machine_id) - if not machine: - return error_response(ErrorCodes.NOT_FOUND, 'Machine 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 = Application.query.get(data['appid']) - if not app: - return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) - - # Check if already installed - existing = InstalledApp.query.filter_by( - machineid=machine_id, - appid=data['appid'] - ).first() - - if existing: - if existing.isactive: - return error_response( - ErrorCodes.CONFLICT, - 'Application already installed on this machine', - http_code=409 - ) - # Reactivate - 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 = InstalledApp( - machineid=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() -def uninstall_application(machine_id: int, app_id: int): - """Uninstall an application from a machine.""" - installed = InstalledApp.query.filter_by( - machineid=machine_id, - appid=app_id, - isactive=True - ).first() - - if not installed: - return error_response(ErrorCodes.NOT_FOUND, 'Application not installed on this machine', http_code=404) - - installed.isactive = False - db.session.commit() - - return success_response(message='Application uninstalled') - - -@applications_bp.route('/machines//', methods=['PUT']) -@jwt_required() -def update_installed_app(machine_id: int, app_id: int): - """Update installed application (e.g., change version).""" - installed = InstalledApp.query.filter_by( - machineid=machine_id, - appid=app_id, - isactive=True - ).first() - - if not installed: - return error_response(ErrorCodes.NOT_FOUND, 'Application not installed on this machine', 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 ---- - -@applications_bp.route('/supportteams', methods=['GET']) -@jwt_required(optional=True) -def list_support_teams(): - """List all support teams.""" - teams = SupportTeam.query.filter_by(isactive=True).order_by(SupportTeam.teamname).all() - data = [] - for team in teams: - team_dict = team.to_dict() - team_dict['owner'] = team.owner.appowner if team.owner else None - data.append(team_dict) - return success_response(data) - - -@applications_bp.route('/supportteams', methods=['POST']) -@jwt_required() -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') - - team = SupportTeam( - teamname=data['teamname'], - teamurl=data.get('teamurl'), - appownerid=data.get('appownerid') - ) - - db.session.add(team) - db.session.commit() - - return success_response(team.to_dict(), message='Support team created', http_code=201) - - -# ---- App Owners ---- - -@applications_bp.route('/appowners', methods=['GET']) -@jwt_required(optional=True) -def list_app_owners(): - """List all application owners.""" - owners = AppOwner.query.filter_by(isactive=True).order_by(AppOwner.appowner).all() - return success_response([o.to_dict() for o in owners]) - - -@applications_bp.route('/appowners', methods=['POST']) -@jwt_required() -def create_app_owner(): - """Create a new application owner.""" - data = request.get_json() - if not data or not data.get('appowner'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'appowner is required') - - owner = AppOwner( - appowner=data['appowner'], - sso=data.get('sso'), - email=data.get('email') - ) - - db.session.add(owner) - db.session.commit() - - return success_response(owner.to_dict(), message='App owner created', http_code=201) +"""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, AppOwner, SupportTeam, AuditLog +) +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + + +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 _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 + + +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) + + 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: + app_dict = app.to_dict() + if app.supportteam: + app_dict['supportteam'] = { + 'supportteamid': app.supportteam.supportteamid, + 'teamname': app.supportteam.teamname, + 'teamurl': app.supportteam.teamurl, + 'owner': { + 'appownerid': app.supportteam.owner.appownerid, + 'appowner': app.supportteam.owner.appowner, + 'sso': app.supportteam.owner.sso + } if app.supportteam.owner else None + } + else: + app_dict['supportteam'] = None + 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 = Application.query.get(app_id) + + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + data = app.to_dict() + if app.supportteam: + data['supportteam'] = { + 'supportteamid': app.supportteam.supportteamid, + 'teamname': app.supportteam.teamname, + 'teamurl': app.supportteam.teamurl, + 'owner': { + 'appownerid': app.supportteam.owner.appownerid, + 'appowner': app.supportteam.owner.appowner, + 'sso': app.supportteam.owner.sso + } if app.supportteam.owner else None + } + else: + data['supportteam'] = None + 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) + + return success_response(data) + + +@applications_bp.route('', methods=['POST']) +@jwt_required() +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) + 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() +def update_application(app_id: int): + """Update an application.""" + app = Application.query.get(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) + + db.session.commit() + return success_response(app.to_dict(), message='Application updated') + + +@applications_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_application(app_id: int): + """Delete (deactivate) an application.""" + app = Application.query.get(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 = Application.query.get(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() +def create_version(app_id: int): + """Create a new version for an application.""" + app = Application.query.get(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) + 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 = Application.query.get(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 = Computer.query.get(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() +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 = Computer.query.get(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 = Application.query.get(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() +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() +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 ---- + +@applications_bp.route('/supportteams', methods=['GET']) +@jwt_required(optional=True) +def list_support_teams(): + """List all support teams.""" + teams = SupportTeam.query.filter_by(isactive=True).order_by(SupportTeam.teamname).all() + data = [] + for team in teams: + team_dict = team.to_dict() + team_dict['owner'] = team.owner.appowner if team.owner else None + data.append(team_dict) + return success_response(data) + + +@applications_bp.route('/supportteams', methods=['POST']) +@jwt_required() +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') + + team = SupportTeam( + teamname=data['teamname'], + teamurl=data.get('teamurl'), + appownerid=data.get('appownerid') + ) + + db.session.add(team) + db.session.commit() + + return success_response(team.to_dict(), message='Support team created', http_code=201) + + +# ---- App Owners ---- + +@applications_bp.route('/appowners', methods=['GET']) +@jwt_required(optional=True) +def list_app_owners(): + """List all application owners.""" + owners = AppOwner.query.filter_by(isactive=True).order_by(AppOwner.appowner).all() + return success_response([o.to_dict() for o in owners]) + + +@applications_bp.route('/appowners', methods=['POST']) +@jwt_required() +def create_app_owner(): + """Create a new application owner.""" + data = request.get_json() + if not data or not data.get('appowner'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'appowner is required') + + owner = AppOwner( + appowner=data['appowner'], + sso=data.get('sso'), + email=data.get('email') + ) + + db.session.add(owner) + db.session.commit() + + return success_response(owner.to_dict(), message='App owner created', http_code=201) diff --git a/shopdb/core/api/assets.py b/shopdb/core/api/assets.py index 00af216..2536518 100644 --- a/shopdb/core/api/assets.py +++ b/shopdb/core/api/assets.py @@ -1,826 +1,921 @@ -"""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 - -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 = AssetType.query.get(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() -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') - ) - - db.session.add(t) - db.session.commit() - - return success_response(t.to_dict(), message='Asset type created', http_code=201) - - -# ============================================================================= -# 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 = AssetStatus.query.get(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() -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 -# ============================================================================= - -@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., 'equipment', '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('type_id'): - query = query.filter(Asset.assettypeid == int(type_id)) - - # Status filter - if status_id := request.args.get('status_id'): - query = query.filter(Asset.statusid == int(status_id)) - - # Location filter - if location_id := request.args.get('location_id'): - query = query.filter(Asset.locationid == int(location_id)) - - # Business unit filter - if bu_id := 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 = Asset.query.get(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() -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 AssetType.query.get(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) - db.session.commit() - - return success_response(asset.to_dict(), message='Asset created', http_code=201) - - -@assets_bp.route('/', methods=['PUT']) -@jwt_required() -def update_asset(asset_id: int): - """Update an asset.""" - asset = Asset.query.get(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]) - - db.session.commit() - return success_response(asset.to_dict(), message='Asset updated') - - -@assets_bp.route('/', methods=['DELETE']) -@jwt_required() -def delete_asset(asset_id: int): - """Delete (soft delete) an asset.""" - asset = Asset.query.get(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 -# ============================================================================= - -@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 = Asset.query.get(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 - 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 - incoming_data.append(r) - - return success_response({ - 'outgoing': outgoing_data, - 'incoming': incoming_data - }) - - -@assets_bp.route('/relationships', methods=['POST']) -@jwt_required() -def create_asset_relationship(): - """Create a relationship between two assets.""" - 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 Asset.query.get(source_id): - return error_response(ErrorCodes.NOT_FOUND, f'Source asset {source_id} not found', http_code=404) - if not Asset.query.get(target_id): - return error_response(ErrorCodes.NOT_FOUND, f'Target asset {target_id} not found', http_code=404) - if not RelationshipType.query.get(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) - db.session.commit() - - return success_response(rel.to_dict(), message='Relationship created', http_code=201) - - -@assets_bp.route('/relationships/', methods=['DELETE']) -@jwt_required() -def delete_asset_relationship(rel_id: int): - """Delete an asset relationship.""" - rel = AssetRelationship.query.get(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 (equipment, computer, network_device, printer) - - subtype: Filter by subtype ID (machinetype for equipment/computer, 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, MachineType, Machine, 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.equipment.models import Equipment - eager_options.append( - subqueryload(Asset.equipment) - .joinedload(Equipment.equipmenttype) - ) - eager_options.append( - subqueryload(Asset.equipment) - .joinedload(Equipment.vendor) - ) - eager_options.append( - subqueryload(Asset.equipment) - .joinedload(Equipment.model) - ) - eager_options.append( - subqueryload(Asset.equipment) - .joinedload(Equipment.controllervendor) - ) - eager_options.append( - subqueryload(Asset.equipment) - .joinedload(Equipment.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 - - query = Asset.query.options(*eager_options).filter( - Asset.isactive == True, - Asset.mapx.isnot(None), - Asset.mapy.isnot(None) - ) - - 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) - asset_type_lower = selected_assettype.lower() if selected_assettype else '' - if asset_type_lower == 'equipment': - try: - from plugins.equipment.models import Equipment - query = query.join(Equipment, Equipment.assetid == Asset.assetid).filter( - Equipment.equipmenttypeid == 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 - - # 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 - - 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} 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.equipment.models import EquipmentType - equipment_types = EquipmentType.query.filter(EquipmentType.isactive == True).order_by(EquipmentType.equipmenttype).all() - subtypes['Equipment'] = [{'id': et.equipmenttypeid, 'name': et.equipmenttype} for et in equipment_types] - except ImportError: - subtypes['Equipment'] = [] - - 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} 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} 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} for pt in printer_types] - except ImportError: - subtypes['Printer'] = [] - - 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 = Asset.query.get(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) +"""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 + +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 = AssetType.query.get(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() +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') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Asset type created', http_code=201) + + +# ============================================================================= +# 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 = AssetStatus.query.get(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() +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() +def update_asset_status(status_id: int): + """Update an asset status.""" + s = AssetStatus.query.get(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() +def delete_asset_status(status_id: int): + """Delete an asset status. Refused if any asset still uses it.""" + s = AssetStatus.query.get(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([{ + 'relationshiptypeid': t.relationshiptypeid, + 'relationshiptype': t.relationshiptype, + 'description': t.description + } for t in types]) + + +@assets_bp.route('/relationshiptypes', methods=['POST']) +@jwt_required() +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') + ) + db.session.add(rel_type) + db.session.commit() + + return success_response({ + 'relationshiptypeid': rel_type.relationshiptypeid, + 'relationshiptype': rel_type.relationshiptype, + 'description': rel_type.description + }, message='Relationship type created', http_code=201) + + +# ============================================================================= +# 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., 'equipment', '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 = Asset.query.get(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() +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 AssetType.query.get(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) + db.session.commit() + + return success_response(asset.to_dict(), message='Asset created', http_code=201) + + +@assets_bp.route('/', methods=['PUT']) +@jwt_required() +def update_asset(asset_id: int): + """Update an asset.""" + asset = Asset.query.get(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]) + + db.session.commit() + return success_response(asset.to_dict(), message='Asset updated') + + +@assets_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_asset(asset_id: int): + """Delete (soft delete) an asset.""" + asset = Asset.query.get(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 +# ============================================================================= + +@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 = Asset.query.get(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 + 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 + incoming_data.append(r) + + return success_response({ + 'outgoing': outgoing_data, + 'incoming': incoming_data + }) + + +@assets_bp.route('/relationships', methods=['POST']) +@jwt_required() +def create_asset_relationship(): + """Create a relationship between two assets.""" + 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 Asset.query.get(source_id): + return error_response(ErrorCodes.NOT_FOUND, f'Source asset {source_id} not found', http_code=404) + if not Asset.query.get(target_id): + return error_response(ErrorCodes.NOT_FOUND, f'Target asset {target_id} not found', http_code=404) + if not RelationshipType.query.get(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) + db.session.commit() + + return success_response(rel.to_dict(), message='Relationship created', http_code=201) + + +@assets_bp.route('/relationships/', methods=['DELETE']) +@jwt_required() +def delete_asset_relationship(rel_id: int): + """Delete an asset relationship.""" + rel = AssetRelationship.query.get(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 (equipment, computer, network_device, printer) + - subtype: Filter by subtype ID (machinetype for equipment/computer, 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.equipment.models import Equipment + eager_options.append( + subqueryload(Asset.equipment) + .joinedload(Equipment.equipmenttype) + ) + eager_options.append( + subqueryload(Asset.equipment) + .joinedload(Equipment.vendor) + ) + eager_options.append( + subqueryload(Asset.equipment) + .joinedload(Equipment.model) + ) + eager_options.append( + subqueryload(Asset.equipment) + .joinedload(Equipment.controllervendor) + ) + eager_options.append( + subqueryload(Asset.equipment) + .joinedload(Equipment.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 + + query = Asset.query.options(*eager_options).filter( + Asset.isactive == True, + Asset.mapx.isnot(None), + Asset.mapy.isnot(None) + ) + + 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) + asset_type_lower = selected_assettype.lower() if selected_assettype else '' + if asset_type_lower == 'equipment': + try: + from plugins.equipment.models import Equipment + query = query.join(Equipment, Equipment.assetid == Asset.assetid).filter( + Equipment.equipmenttypeid == 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 + + # 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 + + 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} 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.equipment.models import EquipmentType + equipment_types = EquipmentType.query.filter(EquipmentType.isactive == True).order_by(EquipmentType.equipmenttype).all() + subtypes['Equipment'] = [{'id': et.equipmenttypeid, 'name': et.equipmenttype} for et in equipment_types] + except ImportError: + subtypes['Equipment'] = [] + + 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} 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} 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} for pt in printer_types] + except ImportError: + subtypes['Printer'] = [] + + 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 = Asset.query.get(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/collector.py b/shopdb/core/api/collector.py index 2f15dfc..39a8528 100644 --- a/shopdb/core/api/collector.py +++ b/shopdb/core/api/collector.py @@ -1,374 +1,412 @@ -""" -PowerShell Data Collection API endpoints. - -Compatibility layer for existing PowerShell scripts that update PC data. -Uses API key authentication instead of JWT for automated scripts. -""" - -from datetime import datetime -from functools import wraps -from flask import Blueprint, request, current_app - -from shopdb.extensions import db -from shopdb.core.models import Machine, Application, InstalledApp -from shopdb.utils.responses import success_response, error_response, ErrorCodes - -collector_bp = Blueprint('collector', __name__) - - -def require_api_key(f): - """Decorator to require API key authentication.""" - @wraps(f) - def decorated(*args, **kwargs): - api_key = request.headers.get('X-API-Key') - if not api_key: - api_key = request.args.get('api_key') - - expected_key = current_app.config.get('COLLECTOR_API_KEY') - - if not expected_key: - return error_response( - ErrorCodes.INTERNAL_ERROR, - 'Collector API key not configured', - http_code=500 - ) - - if api_key != expected_key: - return error_response( - ErrorCodes.UNAUTHORIZED, - 'Invalid API key', - http_code=401 - ) - - return f(*args, **kwargs) - return decorated - - -@collector_bp.route('/pc', methods=['POST']) -@require_api_key -def update_pc_info(): - """ - Update PC information from PowerShell collection script. - - Expected JSON payload: - { - "hostname": "PC-1234", - "osname": "Windows 10 Enterprise", - "osversion": "10.0.19045", - "lastboottime": "2024-01-15T08:30:00", - "currentuser": "jsmith", - "ipaddress": "10.1.2.100", - "macaddress": "00:11:22:33:44:55", - "serialnumber": "ABC123", - "manufacturer": "Dell", - "model": "OptiPlex 7090" - } - """ - 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') - - # Find the PC by hostname - pc = Machine.query.filter( - Machine.hostname.ilike(hostname), - Machine.pctypeid.isnot(None) - ).first() - - if not pc: - # Try to find by machine number if hostname not found - pc = Machine.query.filter( - Machine.machinenumber.ilike(hostname), - Machine.pctypeid.isnot(None) - ).first() - - if not pc: - return error_response( - ErrorCodes.NOT_FOUND, - f'PC with hostname {hostname} not found', - http_code=404 - ) - - # Update PC fields - update_fields = { - 'lastzabbixsync': datetime.utcnow(), # Track last collection time - } - - if data.get('lastboottime'): - try: - update_fields['lastboottime'] = datetime.fromisoformat( - data['lastboottime'].replace('Z', '+00:00') - ) - except ValueError: - pass - - if data.get('currentuser'): - # Store previous user before updating - if pc.currentuserid != data['currentuser']: - update_fields['lastuserid'] = pc.currentuserid - update_fields['currentuserid'] = data['currentuser'] - - if data.get('serialnumber'): - update_fields['serialnumber'] = data['serialnumber'] - - # Update the record - for key, value in update_fields.items(): - if hasattr(pc, key): - setattr(pc, key, value) - - db.session.commit() - - return success_response({ - 'machineid': pc.machineid, - 'hostname': pc.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. - - Expected JSON payload: - { - "hostname": "PC-1234", - "apps": [ - { - "appname": "Microsoft Office", - "version": "16.0.14326.20454", - "installdate": "2024-01-10" - }, - ... - ] - } - """ - 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') - - # Find the PC - pc = Machine.query.filter( - Machine.hostname.ilike(hostname), - Machine.pctypeid.isnot(None) - ).first() - - if not pc: - return error_response( - ErrorCodes.NOT_FOUND, - f'PC with hostname {hostname} not found', - http_code=404 - ) - - updated_count = 0 - created_count = 0 - skipped_count = 0 - - for app_data in apps: - app_name = app_data.get('appname') - if not app_name: - skipped_count += 1 - continue - - # Find the application in the database - app = Application.query.filter( - Application.appname.ilike(app_name) - ).first() - - if not app: - # Skip apps not in our tracked list - skipped_count += 1 - continue - - # Check if already installed - installed = InstalledApp.query.filter_by( - machineid=pc.machineid, - appid=app.appid - ).first() - - if installed: - # Update version if changed - new_version = app_data.get('version') - if new_version and installed.installedversion != new_version: - installed.installedversion = new_version - installed.modifieddate = datetime.utcnow() - updated_count += 1 - else: - # Create new installed app record - installed = InstalledApp( - machineid=pc.machineid, - appid=app.appid, - installedversion=app_data.get('version'), - installdate=datetime.utcnow() - ) - db.session.add(installed) - created_count += 1 - - db.session.commit() - - return success_response({ - 'hostname': hostname, - 'machineid': pc.machineid, - '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. - - Expected JSON payload: - { - "hostname": "PC-1234" - } - - Or batch update: - { - "hostnames": ["PC-1234", "PC-1235", "PC-1236"] - } - """ - 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 = [] - - for hostname in hostnames: - pc = Machine.query.filter( - Machine.hostname.ilike(hostname), - Machine.pctypeid.isnot(None) - ).first() - - if pc: - pc.lastzabbixsync = datetime.utcnow() - updated += 1 - else: - not_found.append(hostname) - - db.session.commit() - - return success_response({ - 'updated': updated, - 'notfound': not_found, - 'timestamp': datetime.utcnow().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. - - Expected JSON payload: - { - "pcs": [ - { - "hostname": "PC-1234", - "currentuser": "jsmith", - "lastboottime": "2024-01-15T08:30:00" - }, - ... - ] - } - """ - 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 = [] - - for pc_data in pcs: - hostname = pc_data.get('hostname') - if not hostname: - continue - - pc = Machine.query.filter( - Machine.hostname.ilike(hostname), - Machine.pctypeid.isnot(None) - ).first() - - if not pc: - not_found.append(hostname) - continue - - try: - pc.lastzabbixsync = datetime.utcnow() - - if pc_data.get('currentuser'): - if pc.currentuserid != pc_data['currentuser']: - pc.lastuserid = pc.currentuserid - pc.currentuserid = pc_data['currentuser'] - - if pc_data.get('lastboottime'): - try: - pc.lastboottime = datetime.fromisoformat( - pc_data['lastboottime'].replace('Z', '+00:00') - ) - except ValueError: - pass - - updated += 1 - - except Exception as e: - errors.append({'hostname': hostname, 'error': str(e)}) - - db.session.commit() - - return success_response({ - 'updated': updated, - 'notfound': not_found, - 'errors': errors, - 'timestamp': datetime.utcnow().isoformat() - }, message=f'{updated} PC(s) updated') - - -@collector_bp.route('/status', methods=['GET']) -@require_api_key -def collector_status(): - """Check collector API status and configuration.""" - return success_response({ - 'status': 'ok', - 'timestamp': datetime.utcnow().isoformat(), - 'endpoints': [ - 'POST /api/collector/pc', - 'POST /api/collector/apps', - 'POST /api/collector/heartbeat', - 'POST /api/collector/bulk', - 'GET /api/collector/status' - ] - }) +""" +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 +from functools import wraps +from flask import Blueprint, request, current_app + +from shopdb.extensions import db +from shopdb.core.models import Asset, Application + +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +collector_bp = Blueprint('collector', __name__) + + +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 require_api_key(f): + """Require API key authentication.""" + @wraps(f) + def decorated(*args, **kwargs): + api_key = request.headers.get('X-API-Key') or request.args.get('api_key') + expected_key = current_app.config.get('COLLECTOR_API_KEY') + + if not expected_key: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Collector API key not configured', + http_code=500 + ) + if api_key != expected_key: + return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key', + http_code=401) + 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] + + expected_key = _plugin_api_key(pluginname) + if not expected_key: + return error_response(ErrorCodes.INTERNAL_ERROR, + 'Collector API key not configured', http_code=500) + api_key = request.headers.get('X-API-Key') or request.args.get('api_key') + if api_key != expected_key: + return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key', + http_code=401) + + 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: + db.session.rollback() + return error_response(ErrorCodes.VALIDATION_ERROR, str(exc)) + except Exception as exc: + db.session.rollback() + current_app.logger.exception('Collector upsert failed for %s', pluginname) + return error_response(ErrorCodes.INTERNAL_ERROR, str(exc), 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() + + return success_response({ + 'status': 'ok', + 'action': action, + 'assetid': outcome.get('assetid'), + 'identityvalue': outcome.get('identityvalue', identityvalue), + 'warnings': outcome.get('warnings', []), + }, 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.utcnow() + + 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.utcnow() + 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.utcnow() + + 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 as exc: + errors.append({'hostname': hostname, 'error': str(exc)}) + + 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.utcnow().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/dashboard.py b/shopdb/core/api/dashboard.py index f93b874..3dab775 100644 --- a/shopdb/core/api/dashboard.py +++ b/shopdb/core/api/dashboard.py @@ -4,74 +4,79 @@ from flask import Blueprint, current_app from flask_jwt_extended import jwt_required from shopdb.extensions import db -from shopdb.core.models import Machine, MachineType, MachineStatus +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 = { + 'equipment': 'Equipment', + 'computer': 'PC', + 'printer': 'Printer', + 'network_device': 'Network', +} + + +def _count_by_type(assettype): + return db.session.query(Asset).join(AssetType).filter( + Asset.isactive == True, + AssetType.assettype == assettype + ).count() + @dashboard_bp.route('/summary', methods=['GET']) @dashboard_bp.route('', methods=['GET']) @jwt_required(optional=True) def get_dashboard(): - """Get dashboard summary data.""" - # Count machines by category - equipment_count = db.session.query(Machine).join(MachineType).filter( - Machine.isactive == True, - MachineType.category == 'Equipment' - ).count() - - pc_count = db.session.query(Machine).join(MachineType).filter( - Machine.isactive == True, - MachineType.category == 'PC' - ).count() - - network_count = db.session.query(Machine).join(MachineType).filter( - Machine.isactive == True, - MachineType.category == 'Network' - ).count() + """Get dashboard summary data (asset-based).""" + equipment_count = _count_by_type('equipment') + pc_count = _count_by_type('computer') + network_count = _count_by_type('network_device') + printer_count = _count_by_type('printer') + total = equipment_count + pc_count + network_count + printer_count # Count by status status_counts = db.session.query( - MachineStatus.status, - db.func.count(Machine.machineid) + AssetStatus.status, + db.func.count(Asset.assetid) ).outerjoin( - Machine, - db.and_(Machine.statusid == MachineStatus.statusid, Machine.isactive == True) - ).group_by(MachineStatus.status).all() - - # Recent machines - recent_machines = Machine.query.filter_by(isactive=True).order_by( - Machine.createddate.desc() - ).limit(10).all() - - # Build status dict + 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': equipment_count + pc_count + network_count, + 'totalmachines': total, 'totalequipment': equipment_count, 'totalpc': pc_count, 'totalnetwork': network_count, + 'totalprinter': printer_count, 'activemachines': status_dict.get('In Use', 0), 'inrepair': status_dict.get('In Repair', 0), - # Also include structured data + # Structured data 'counts': { 'equipment': equipment_count, 'pcs': pc_count, 'networkdevices': network_count, - 'total': equipment_count + pc_count + network_count + 'printers': printer_count, + 'total': total }, 'bystatus': status_dict, 'recent': [ { - 'machineid': m.machineid, - 'machinenumber': m.machinenumber, - 'machinetype': m.machinetype.machinetype if m.machinetype else None, - 'createddate': m.createddate.isoformat() + 'Z' if m.createddate else None + '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 m in recent_machines + for a in recent ] }) @@ -79,23 +84,23 @@ def get_dashboard(): @dashboard_bp.route('/stats', methods=['GET']) @jwt_required(optional=True) def get_stats(): - """Get detailed statistics.""" - # Machine type breakdown + """Get detailed statistics by asset type.""" type_counts = db.session.query( - MachineType.machinetype, - MachineType.category, - db.func.count(Machine.machineid) + AssetType.assettype, + db.func.count(Asset.assetid) ).outerjoin( - Machine, - db.and_(Machine.machinetypeid == MachineType.machinetypeid, Machine.isactive == True) - ).filter(MachineType.isactive == True).group_by( - MachineType.machinetypeid - ).all() + Asset, + db.and_(Asset.assettypeid == AssetType.assettypeid, Asset.isactive == True) + ).group_by(AssetType.assettypeid).all() return success_response({ 'bytype': [ - {'type': t, 'category': c, 'count': count} - for t, c, count in type_counts + { + 'type': t, + 'category': _TYPE_CATEGORY.get(t, t), + 'count': count + } + for t, count in type_counts ] }) @@ -115,15 +120,24 @@ def get_navigation(): {'name': 'Map', 'icon': 'map', 'route': '/map', 'position': 4}, ]) - # Collect navigation items from all plugins + # 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: - pass + # 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([ @@ -138,11 +152,41 @@ def get_navigation(): 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).""" try: - # Test database connection db.session.execute(db.text('SELECT 1')) db_status = 'healthy' except Exception as e: diff --git a/shopdb/core/api/employees.py b/shopdb/core/api/employees.py index 42791e8..5624655 100644 --- a/shopdb/core/api/employees.py +++ b/shopdb/core/api/employees.py @@ -1,161 +1,144 @@ -"""Employee lookup API endpoints.""" - -from flask import Blueprint, request -from shopdb.utils.responses import success_response, error_response, ErrorCodes - -employees_bp = Blueprint('employees', __name__) - - -@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' - ) - - try: - import pymysql - conn = pymysql.connect( - host='localhost', - user='root', - password='rootpassword', - database='wjf_employees', - cursorclass=pymysql.cursors.DictCursor - ) - - with conn.cursor() as cur: - # Search by first name, last name, or SSO - cur.execute(''' - 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 - ''', (f'%{query}%', f'%{query}%', f'%{query}%', limit)) - - employees = cur.fetchall() - - conn.close() - - return success_response(employees) - - except Exception as e: - return error_response( - ErrorCodes.DATABASE_ERROR, - f'Employee lookup failed: {str(e)}', - 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' - ) - - try: - import pymysql - conn = pymysql.connect( - host='localhost', - user='root', - password='rootpassword', - database='wjf_employees', - cursorclass=pymysql.cursors.DictCursor - ) - - with conn.cursor() as cur: - cur.execute( - 'SELECT SSO, First_Name, Last_Name, Team, Role, Picture 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(employee) - - except Exception as e: - return error_response( - ErrorCodes.DATABASE_ERROR, - f'Employee lookup failed: {str(e)}', - 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' - ) - - try: - import pymysql - conn = pymysql.connect( - host='localhost', - user='root', - password='rootpassword', - database='wjf_employees', - cursorclass=pymysql.cursors.DictCursor - ) - - with conn.cursor() as cur: - placeholders = ','.join(['%s'] * len(ssos)) - cur.execute( - f'SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO IN ({placeholders})', - [int(s) for s in ssos] - ) - employees = cur.fetchall() - - conn.close() - - # Build name string - names = ', '.join( - f"{e['First_Name'].strip()} {e['Last_Name'].strip()}" - for e in employees - ) - - return success_response({ - 'employees': employees, - 'names': names - }) - - except Exception as e: - return error_response( - ErrorCodes.DATABASE_ERROR, - f'Employee lookup failed: {str(e)}', - http_code=500 - ) +"""Employee lookup API endpoints. + +These read from the separate employee directory DB (see shopdb.utils.employee_db). +They 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 logging + +from flask import Blueprint, request +from shopdb.utils.responses import success_response, error_response, ErrorCodes +from shopdb.utils.employee_db import employee_connection + +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' + + +@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' + ) + + 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(employees) + except Exception: + logger.exception('Employee search failed') + return error_response( + ErrorCodes.DATABASE_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' + ) + + 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(employee) + except Exception: + logger.exception('Employee lookup failed for SSO %s', sso) + return error_response( + ErrorCodes.DATABASE_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' + ) + + 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() + + 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.DATABASE_ERROR, + 'Employee lookup failed', + http_code=500 + ) diff --git a/shopdb/core/api/locations.py b/shopdb/core/api/locations.py index cb88386..8e963ec 100644 --- a/shopdb/core/api/locations.py +++ b/shopdb/core/api/locations.py @@ -4,7 +4,7 @@ from flask import Blueprint, request from flask_jwt_extended import jwt_required from shopdb.extensions import db -from shopdb.core.models import Location +from shopdb.core.models import Location, LocationType from shopdb.utils.responses import ( success_response, error_response, @@ -16,6 +16,19 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query locations_bp = Blueprint('locations', __name__) +@locations_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_location_types(): + """List all location types.""" + types = LocationType.query.filter_by(isactive=True).order_by( + LocationType.locationtype).all() + return success_response([{ + 'locationtypeid': t.locationtypeid, + 'locationtype': t.locationtype, + 'description': t.description, + } for t in types]) + + @locations_bp.route('', methods=['GET']) @jwt_required(optional=True) def list_locations(): @@ -81,6 +94,8 @@ def create_location(): 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') @@ -117,7 +132,9 @@ def update_location(location_id: int): http_code=409 ) - for key in ['locationname', 'building', 'floor', 'room', 'description', 'mapimage', 'mapwidth', 'mapheight', 'isactive']: + for key in ['locationname', 'building', 'floor', 'room', 'description', + 'locationtypeid', 'parentlocationid', 'mapimage', 'mapwidth', + 'mapheight', 'isactive']: if key in data: setattr(loc, key, data[key]) diff --git a/shopdb/core/api/machines.py b/shopdb/core/api/machines.py deleted file mode 100644 index 4a9ed3d..0000000 --- a/shopdb/core/api/machines.py +++ /dev/null @@ -1,641 +0,0 @@ -""" -Machines API endpoints. - -DEPRECATED: This API is deprecated and will be removed in a future version. -Please migrate to the new asset-based APIs: -- /api/assets - Unified asset queries -- /api/equipment - Equipment CRUD -- /api/computers - Computers CRUD -- /api/network - Network devices CRUD -- /api/printers - Printers CRUD -""" - -import logging -from functools import wraps -from flask import Blueprint, request, g -from flask_jwt_extended import jwt_required, current_user - -from shopdb.extensions import db -from shopdb.core.models import Machine, MachineType, AuditLog -from shopdb.core.models.relationship import MachineRelationship, RelationshipType -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query - -logger = logging.getLogger(__name__) - -machines_bp = Blueprint('machines', __name__) - - -def add_deprecation_headers(f): - """Decorator to add deprecation headers to responses.""" - @wraps(f) - def decorated_function(*args, **kwargs): - response = f(*args, **kwargs) - - # Add deprecation headers - if hasattr(response, 'headers'): - response.headers['X-Deprecated'] = 'true' - response.headers['X-Deprecated-Message'] = ( - 'This endpoint is deprecated. ' - 'Please migrate to /api/assets, /api/equipment, /api/computers, /api/network, or /api/printers.' - ) - response.headers['Sunset'] = '2026-12-31' # Target sunset date - - # Log deprecation warning (once per request) - if not getattr(g, '_deprecation_logged', False): - logger.warning( - f"Deprecated /api/machines endpoint called: {request.method} {request.path}" - ) - g._deprecation_logged = True - - return response - return decorated_function - - -@machines_bp.route('', methods=['GET']) -@jwt_required(optional=True) -@add_deprecation_headers -def list_machines(): - """ - List all machines with filtering and pagination. - - Query params: - page: int (default 1) - per_page: int (default 20, max 100) - machinetype: int (filter by type ID) - pctype: int (filter by PC type ID) - businessunit: int (filter by business unit ID) - status: int (filter by status ID) - category: str (Equipment, PC, Network) - search: str (search in machinenumber, alias, hostname) - active: bool (default true) - sort: str (field name, prefix with - for desc) - """ - page, per_page = get_pagination_params(request) - - # Build query - query = Machine.query - - # Apply filters - if request.args.get('active', 'true').lower() != 'false': - query = query.filter(Machine.isactive == True) - - if machinetype_id := request.args.get('machinetype', type=int): - query = query.filter(Machine.machinetypeid == machinetype_id) - - if pctype_id := request.args.get('pctype', type=int): - query = query.filter(Machine.pctypeid == pctype_id) - - if businessunit_id := request.args.get('businessunit', type=int): - query = query.filter(Machine.businessunitid == businessunit_id) - - if status_id := request.args.get('status', type=int): - query = query.filter(Machine.statusid == status_id) - - if category := request.args.get('category'): - query = query.join(MachineType).filter(MachineType.category == category) - - if search := request.args.get('search'): - search_term = f'%{search}%' - query = query.filter( - db.or_( - Machine.machinenumber.ilike(search_term), - Machine.alias.ilike(search_term), - Machine.hostname.ilike(search_term), - Machine.serialnumber.ilike(search_term) - ) - ) - - # Filter for machines with map positions - if request.args.get('hasmap', '').lower() == 'true': - query = query.filter( - Machine.mapleft.isnot(None), - Machine.maptop.isnot(None) - ) - - # Apply sorting - sort_field = request.args.get('sort', 'machinenumber') - desc = sort_field.startswith('-') - if desc: - sort_field = sort_field[1:] - - if hasattr(Machine, sort_field): - order = getattr(Machine, sort_field) - query = query.order_by(order.desc() if desc else order) - - # For map view, allow fetching all machines without pagination limit - include_map_extras = request.args.get('hasmap', '').lower() == 'true' - fetch_all = request.args.get('all', '').lower() == 'true' - - if include_map_extras and fetch_all: - # Get all map machines without pagination - items = query.all() - total = len(items) - else: - # Normal pagination - items, total = paginate_query(query, page, per_page) - - # Convert to dicts with relationships - data = [] - for m in items: - d = m.to_dict() - # Get machinetype from model (single source of truth) - mt = m.derived_machinetype - d['machinetype'] = mt.machinetype if mt else None - d['machinetypeid'] = mt.machinetypeid if mt else None - d['category'] = mt.category if mt else None - d['status'] = m.status.status if m.status else None - d['statusid'] = m.statusid - d['businessunit'] = m.businessunit.businessunit if m.businessunit else None - d['businessunitid'] = m.businessunitid - d['vendor'] = m.vendor.vendor if m.vendor else None - d['model'] = m.model.modelnumber if m.model else None - d['pctype'] = m.pctype.pctype if m.pctype else None - d['serialnumber'] = m.serialnumber - d['isvnc'] = m.isvnc - d['iswinrm'] = m.iswinrm - - # Include extra fields for map view - if include_map_extras: - # Get primary IP address from communications - primary_comm = next( - (c for c in m.communications if c.isprimary and c.ipaddress), - None - ) - if not primary_comm: - # Fall back to first communication with IP - primary_comm = next( - (c for c in m.communications if c.ipaddress), - None - ) - d['ipaddress'] = primary_comm.ipaddress if primary_comm else None - - # Get connected PC (parent machine that is a PC) - connected_pc = None - for rel in m.parent_relationships: - if rel.parent_machine and rel.parent_machine.is_pc: - connected_pc = rel.parent_machine.machinenumber - break - d['connected_pc'] = connected_pc - - data.append(d) - - return paginated_response(data, page, per_page, total) - - -@machines_bp.route('/', methods=['GET']) -@jwt_required(optional=True) -@add_deprecation_headers -def get_machine(machine_id: int): - """Get a single machine by ID.""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response( - ErrorCodes.NOT_FOUND, - f'Machine with ID {machine_id} not found', - http_code=404 - ) - - data = machine.to_dict() - # Add related data - machinetype comes from model (single source of truth) - mt = machine.derived_machinetype - data['machinetype'] = mt.to_dict() if mt else None - data['pctype'] = machine.pctype.to_dict() if machine.pctype else None - data['status'] = machine.status.to_dict() if machine.status else None - data['businessunit'] = machine.businessunit.to_dict() if machine.businessunit else None - data['vendor'] = machine.vendor.to_dict() if machine.vendor else None - data['model'] = machine.model.to_dict() if machine.model else None - data['location'] = machine.location.to_dict() if machine.location else None - data['operatingsystem'] = machine.operatingsystem.to_dict() if machine.operatingsystem else None - - # Add communications - data['communications'] = [c.to_dict() for c in machine.communications.all()] - - return success_response(data) - - -@machines_bp.route('', methods=['POST']) -@jwt_required() -@add_deprecation_headers -def create_machine(): - """Create a new machine.""" - data = request.get_json() - - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - if not data.get('machinenumber'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'machinenumber is required') - - if not data.get('modelnumberid'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'modelnumberid is required (determines machine type)') - - # Check for duplicate machinenumber - if Machine.query.filter_by(machinenumber=data['machinenumber']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"Machine number '{data['machinenumber']}' already exists", - http_code=409 - ) - - # Create machine - allowed_fields = [ - 'machinenumber', 'alias', 'hostname', 'serialnumber', - 'machinetypeid', 'pctypeid', 'businessunitid', 'modelnumberid', - 'vendorid', 'statusid', 'locationid', 'osid', - 'mapleft', 'maptop', 'islocationonly', - 'loggedinuser', 'isvnc', 'iswinrm', 'isshopfloor', - 'requiresmanualconfig', 'notes' - ] - - machine_data = {k: v for k, v in data.items() if k in allowed_fields} - machine = Machine(**machine_data) - machine.createdby = current_user.username - - db.session.add(machine) - db.session.flush() - - # Audit log - AuditLog.log('created', 'Machine', entityid=machine.machineid, - entityname=machine.machinenumber) - - db.session.commit() - - return success_response( - machine.to_dict(), - message='Machine created successfully', - http_code=201 - ) - - -@machines_bp.route('/', methods=['PUT']) -@jwt_required() -@add_deprecation_headers -def update_machine(machine_id: int): - """Update an existing machine.""" - machine = Machine.query.get(machine_id) - - if not machine: - 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') - - # Check for duplicate machinenumber if changed - if 'machinenumber' in data and data['machinenumber'] != machine.machinenumber: - existing = Machine.query.filter_by(machinenumber=data['machinenumber']).first() - if existing: - return error_response( - ErrorCodes.CONFLICT, - f"Machine number '{data['machinenumber']}' already exists", - http_code=409 - ) - - # Update allowed fields - allowed_fields = [ - 'machinenumber', 'alias', 'hostname', 'serialnumber', - 'machinetypeid', 'pctypeid', 'businessunitid', 'modelnumberid', - 'vendorid', 'statusid', 'locationid', 'osid', - 'mapleft', 'maptop', 'islocationonly', - 'loggedinuser', 'isvnc', 'iswinrm', 'isshopfloor', - 'requiresmanualconfig', 'notes', 'isactive' - ] - - # Track changes for audit log - changes = {} - for key, value in data.items(): - if key in allowed_fields: - old_val = getattr(machine, key) - if old_val != value: - changes[key] = {'old': old_val, 'new': value} - setattr(machine, key, value) - - machine.modifiedby = current_user.username - - # Audit log if there were changes - if changes: - AuditLog.log('updated', 'Machine', entityid=machine.machineid, - entityname=machine.machinenumber, changes=changes) - - db.session.commit() - - return success_response(machine.to_dict(), message='Machine updated successfully') - - -@machines_bp.route('/', methods=['DELETE']) -@jwt_required() -@add_deprecation_headers -def delete_machine(machine_id: int): - """Soft delete a machine.""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response( - ErrorCodes.NOT_FOUND, - f'Machine with ID {machine_id} not found', - http_code=404 - ) - - machine.soft_delete(deleted_by=current_user.username) - - # Audit log - AuditLog.log('deleted', 'Machine', entityid=machine.machineid, - entityname=machine.machinenumber) - - db.session.commit() - - return success_response(message='Machine deleted successfully') - - -@machines_bp.route('//communications', methods=['GET']) -@jwt_required(optional=True) -@add_deprecation_headers -def get_machine_communications(machine_id: int): - """Get all communications for a machine.""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response( - ErrorCodes.NOT_FOUND, - f'Machine with ID {machine_id} not found', - http_code=404 - ) - - comms = [c.to_dict() for c in machine.communications.all()] - return success_response(comms) - - -@machines_bp.route('//communication', methods=['PUT']) -@jwt_required() -@add_deprecation_headers -def update_machine_communication(machine_id: int): - """Update machine communication (IP address).""" - from shopdb.core.models.communication import Communication, CommunicationType - - machine = Machine.query.get(machine_id) - - if not machine: - 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') - - # Get or create IP communication type - ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() - if not ip_comtype: - ip_comtype = CommunicationType(comtype='IP', description='IP Network') - db.session.add(ip_comtype) - db.session.flush() - - # Find existing primary communication or create new one - comms = list(machine.communications.all()) - comm = next((c for c in comms if c.isprimary), None) - if not comm: - comm = next((c for c in comms if c.comtypeid == ip_comtype.comtypeid), None) - if not comm: - comm = Communication(machineid=machine_id, comtypeid=ip_comtype.comtypeid) - db.session.add(comm) - - # Update fields - if 'ipaddress' in data: - comm.ipaddress = data['ipaddress'] - if 'isprimary' in data: - comm.isprimary = data['isprimary'] - if 'macaddress' in data: - comm.macaddress = data['macaddress'] - - db.session.commit() - - return success_response({ - 'communicationid': comm.communicationid, - 'ipaddress': comm.ipaddress, - 'isprimary': comm.isprimary, - }, message='Communication updated') - - -# ==================== Machine Relationships ==================== - -@machines_bp.route('//relationships', methods=['GET']) -@jwt_required(optional=True) -@add_deprecation_headers -def get_machine_relationships(machine_id: int): - """Get all relationships for a machine (both parent and child).""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response( - ErrorCodes.NOT_FOUND, - f'Machine with ID {machine_id} not found', - http_code=404 - ) - - relationships = [] - my_category = machine.machinetype.category if machine.machinetype else None - seen_ids = set() - - # Get all relationships involving this machine - all_rels = list(machine.child_relationships) + list(machine.parent_relationships) - - for rel in all_rels: - if rel.relationshipid in seen_ids: - continue - seen_ids.add(rel.relationshipid) - - # Determine the related machine (the one that isn't us) - if rel.parentmachineid == machine.machineid: - related = rel.child_machine - else: - related = rel.parent_machine - - related_category = related.machinetype.category if related and related.machinetype else None - rel_type = rel.relationship_type.relationshiptype if rel.relationship_type else None - - # Determine direction based on relationship type and categories - if rel_type == 'Controls': - # PC controls Equipment - determine from categories - if my_category == 'PC': - direction = 'controls' - else: - direction = 'controlled_by' - elif rel_type == 'Dualpath': - direction = 'dualpath_partner' - else: - # For other types, use parent/child - if rel.parentmachineid == machine.machineid: - direction = 'controls' - else: - direction = 'controlled_by' - - relationships.append({ - 'relationshipid': rel.relationshipid, - 'direction': direction, - 'relatedmachineid': related.machineid if related else None, - 'relatedmachinenumber': related.machinenumber if related else None, - 'relatedmachinealias': related.alias if related else None, - 'relatedcategory': related_category, - 'relationshiptype': rel_type, - 'relationshiptypeid': rel.relationshiptypeid, - 'notes': rel.notes - }) - - return success_response(relationships) - - -@machines_bp.route('//relationships', methods=['POST']) -@jwt_required() -@add_deprecation_headers -def create_machine_relationship(machine_id: int): - """Create a relationship for a machine.""" - machine = Machine.query.get(machine_id) - - if not machine: - 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') - - related_machine_id = data.get('relatedmachineid') - relationship_type_id = data.get('relationshiptypeid') - direction = data.get('direction', 'controlled_by') # 'controls' or 'controlled_by' - - if not related_machine_id: - return error_response(ErrorCodes.VALIDATION_ERROR, 'relatedmachineid is required') - - if not relationship_type_id: - return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptypeid is required') - - related_machine = Machine.query.get(related_machine_id) - if not related_machine: - return error_response( - ErrorCodes.NOT_FOUND, - f'Related machine with ID {related_machine_id} not found', - http_code=404 - ) - - # Determine parent/child based on direction - if direction == 'controls': - parent_id = machine_id - child_id = related_machine_id - else: # controlled_by - parent_id = related_machine_id - child_id = machine_id - - # Check if relationship already exists - existing = MachineRelationship.query.filter_by( - parentmachineid=parent_id, - childmachineid=child_id, - relationshiptypeid=relationship_type_id - ).first() - - if existing: - return error_response( - ErrorCodes.CONFLICT, - 'This relationship already exists', - http_code=409 - ) - - relationship = MachineRelationship( - parentmachineid=parent_id, - childmachineid=child_id, - relationshiptypeid=relationship_type_id, - notes=data.get('notes') - ) - - db.session.add(relationship) - db.session.commit() - - return success_response({ - 'relationshipid': relationship.relationshipid, - 'parentmachineid': relationship.parentmachineid, - 'childmachineid': relationship.childmachineid, - 'relationshiptypeid': relationship.relationshiptypeid - }, message='Relationship created successfully', http_code=201) - - -@machines_bp.route('/relationships/', methods=['DELETE']) -@jwt_required() -@add_deprecation_headers -def delete_machine_relationship(relationship_id: int): - """Delete a machine relationship.""" - relationship = MachineRelationship.query.get(relationship_id) - - if not relationship: - return error_response( - ErrorCodes.NOT_FOUND, - f'Relationship with ID {relationship_id} not found', - http_code=404 - ) - - db.session.delete(relationship) - db.session.commit() - - return success_response(message='Relationship deleted successfully') - - -@machines_bp.route('/relationshiptypes', methods=['GET']) -@jwt_required(optional=True) -@add_deprecation_headers -def list_relationship_types(): - """List all relationship types.""" - types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all() - return success_response([{ - 'relationshiptypeid': t.relationshiptypeid, - 'relationshiptype': t.relationshiptype, - 'description': t.description - } for t in types]) - - -@machines_bp.route('/relationshiptypes', methods=['POST']) -@jwt_required() -@add_deprecation_headers -def create_relationship_type(): - """Create a new relationship type.""" - data = request.get_json() - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - if not data.get('relationshiptype'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptype is required') - - existing = RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first() - if existing: - 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') - ) - - db.session.add(rel_type) - db.session.commit() - - return success_response({ - 'relationshiptypeid': rel_type.relationshiptypeid, - 'relationshiptype': rel_type.relationshiptype, - 'description': rel_type.description - }, message='Relationship type created successfully', http_code=201) diff --git a/shopdb/core/api/machinetypes.py b/shopdb/core/api/machinetypes.py index 5ec6582..9046421 100644 --- a/shopdb/core/api/machinetypes.py +++ b/shopdb/core/api/machinetypes.py @@ -133,12 +133,12 @@ def delete_machinetype(type_id: int): http_code=404 ) - # Check if any machines use this type - from shopdb.core.models import Machine - if Machine.query.filter_by(machinetypeid=type_id, isactive=True).first(): + # Check if any model uses this type + from shopdb.core.models import Model + if Model.query.filter_by(machinetypeid=type_id).first(): return error_response( ErrorCodes.CONFLICT, - 'Cannot delete machine type: machines are using it', + 'Cannot delete machine type: models are using it', http_code=409 ) diff --git a/shopdb/core/api/pctypes.py b/shopdb/core/api/pctypes.py deleted file mode 100644 index 119f785..0000000 --- a/shopdb/core/api/pctypes.py +++ /dev/null @@ -1,141 +0,0 @@ -"""PC Types 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 PCType -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query - -pctypes_bp = Blueprint('pctypes', __name__) - - -@pctypes_bp.route('', methods=['GET']) -@jwt_required(optional=True) -def list_pctypes(): - """List all PC types.""" - page, per_page = get_pagination_params(request) - - query = PCType.query - - if request.args.get('active', 'true').lower() != 'false': - query = query.filter(PCType.isactive == True) - - if search := request.args.get('search'): - query = query.filter(PCType.pctype.ilike(f'%{search}%')) - - query = query.order_by(PCType.pctype) - - items, total = paginate_query(query, page, per_page) - data = [pt.to_dict() for pt in items] - - return paginated_response(data, page, per_page, total) - - -@pctypes_bp.route('/', methods=['GET']) -@jwt_required(optional=True) -def get_pctype(type_id: int): - """Get a single PC type.""" - pt = PCType.query.get(type_id) - - if not pt: - return error_response( - ErrorCodes.NOT_FOUND, - f'PC type with ID {type_id} not found', - http_code=404 - ) - - return success_response(pt.to_dict()) - - -@pctypes_bp.route('', methods=['POST']) -@jwt_required() -def create_pctype(): - """Create a new PC type.""" - data = request.get_json() - - if not data or not data.get('pctype'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'pctype is required') - - if PCType.query.filter_by(pctype=data['pctype']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"PC type '{data['pctype']}' already exists", - http_code=409 - ) - - pt = PCType( - pctype=data['pctype'], - description=data.get('description') - ) - - db.session.add(pt) - db.session.commit() - - return success_response(pt.to_dict(), message='PC type created', http_code=201) - - -@pctypes_bp.route('/', methods=['PUT']) -@jwt_required() -def update_pctype(type_id: int): - """Update a PC type.""" - pt = PCType.query.get(type_id) - - if not pt: - return error_response( - ErrorCodes.NOT_FOUND, - f'PC 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 'pctype' in data and data['pctype'] != pt.pctype: - if PCType.query.filter_by(pctype=data['pctype']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"PC type '{data['pctype']}' already exists", - http_code=409 - ) - - for key in ['pctype', 'description', 'isactive']: - if key in data: - setattr(pt, key, data[key]) - - db.session.commit() - return success_response(pt.to_dict(), message='PC type updated') - - -@pctypes_bp.route('/', methods=['DELETE']) -@jwt_required() -def delete_pctype(type_id: int): - """Delete (deactivate) a PC type.""" - pt = PCType.query.get(type_id) - - if not pt: - return error_response( - ErrorCodes.NOT_FOUND, - f'PC type with ID {type_id} not found', - http_code=404 - ) - - from shopdb.core.models import Machine - if Machine.query.filter_by(pctypeid=type_id, isactive=True).first(): - return error_response( - ErrorCodes.CONFLICT, - 'Cannot delete PC type: machines are using it', - http_code=409 - ) - - pt.isactive = False - db.session.commit() - - return success_response(message='PC type deleted') diff --git a/shopdb/core/api/plugins.py b/shopdb/core/api/plugins.py new file mode 100644 index 0000000..dda5e16 --- /dev/null +++ b/shopdb/core/api/plugins.py @@ -0,0 +1,56 @@ +"""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 + +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('/', methods=['PUT']) +@jwt_required() +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/reports.py b/shopdb/core/api/reports.py index 95d61ce..5d64a54 100644 --- a/shopdb/core/api/reports.py +++ b/shopdb/core/api/reports.py @@ -8,8 +8,8 @@ from flask_jwt_extended import jwt_required from shopdb.extensions import db from shopdb.core.models import ( - Asset, AssetType, AssetStatus, Machine, MachineStatus, - Application, KnowledgeBase, InstalledApp + Asset, AssetType, AssetStatus, + Application, KnowledgeBase ) from shopdb.utils.responses import success_response, error_response, ErrorCodes @@ -316,6 +316,16 @@ def software_compliance(): - 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, @@ -337,29 +347,33 @@ def software_compliance(): if app_filter and str(app.appid) != app_filter: continue - # Get all PCs - total_pcs = Machine.query.filter( - Machine.isactive == True, - Machine.pctypeid.isnot(None) - ).count() + # 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(InstalledApp).join( - Machine, Machine.machineid == InstalledApp.machineid + installed_count = db.session.query(ComputerInstalledApp).join( + Computer, Computer.computerid == ComputerInstalledApp.computerid + ).join( + Asset, Asset.assetid == Computer.assetid ).filter( - InstalledApp.appid == app.appid, - Machine.isactive == True + ComputerInstalledApp.appid == app.appid, + ComputerInstalledApp.isactive == True, + Asset.isactive == True ).count() # Get list of non-compliant PCs - compliant_pc_ids = db.session.query(InstalledApp.machineid).filter( - InstalledApp.appid == app.appid + compliant_pc_ids = db.session.query(ComputerInstalledApp.computerid).filter( + ComputerInstalledApp.appid == app.appid, + ComputerInstalledApp.isactive == True ).subquery() - non_compliant_pcs = Machine.query.filter( - Machine.isactive == True, - Machine.pctypeid.isnot(None), - ~Machine.machineid.in_(compliant_pc_ids) + 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 @@ -372,7 +386,8 @@ def software_compliance(): 'notinstalled': total_pcs - installed_count, 'compliancerate': round(compliance_rate, 1), 'noncompliantpcs': [ - {'machineid': pc.machineid, 'hostname': pc.hostname or pc.machinenumber} + {'computerid': pc.computerid, + 'hostname': pc.hostname or (pc.asset.assetnumber if pc.asset else None)} for pc in non_compliant_pcs ] }) @@ -534,23 +549,26 @@ def pc_relationships(): Query parameters: - format: 'json' (default) or 'csv' """ + # Asset relationships where a computer (source) relates to an equipment + # (target) - the asset-model equivalent of the legacy PC->machine links. sql = db.text(""" SELECT - eq.machinenumber AS machine_number, + eq.assetnumber AS machine_number, v.vendor AS vendor, mo.modelnumber AS model, - pc.machinenumber AS hostname, + COALESCE(cpc.hostname, pc.assetnumber) AS hostname, c.ipaddress AS ip - FROM machinerelationships mr - JOIN machines eq ON mr.parentmachineid = eq.machineid - JOIN machines pc ON mr.childmachineid = pc.machineid - LEFT JOIN communications c ON pc.machineid = c.machineid AND c.isprimary = 1 AND c.comtypeid = 1 - LEFT JOIN models mo ON eq.modelnumberid = mo.modelnumberid + 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 equipment 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 mr.isactive = 1 - AND pc.pctypeid IS NOT NULL - AND eq.machinenumber IS NOT NULL AND eq.machinenumber != '' - ORDER BY eq.machinenumber + WHERE ar.isactive = 1 + AND eq.assetnumber IS NOT NULL AND eq.assetnumber != '' + ORDER BY eq.assetnumber """) results = db.session.execute(sql).fetchall() diff --git a/shopdb/core/api/search.py b/shopdb/core/api/search.py index 27bf054..019962f 100644 --- a/shopdb/core/api/search.py +++ b/shopdb/core/api/search.py @@ -1,727 +1,741 @@ -"""Global search API endpoint with full search parity.""" - -import re -import ipaddress -import logging - -from datetime import datetime -from flask import Blueprint, request -from flask_jwt_extended import jwt_required -from sqlalchemy.orm import joinedload - -from shopdb.extensions import db -from shopdb.core.models import ( - Application, KnowledgeBase, - Asset, AssetType, Communication, Vendor, Model -) -from shopdb.utils.responses import success_response - -logger = logging.getLogger(__name__) - -search_bp = Blueprint('search', __name__) - -# ServiceNOW URL template -SERVICENOW_URL = ( - 'https://geit.service-now.com/now/nav/ui/search/' - '0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/' - 'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/' - 'back-button-label/IT4IT%20Homepage/search-context/now%2Fnav%2Fui' -) - - -def _classify_query(query): - """Analyze the query string to determine its nature.""" - return { - 'is_ip': bool(re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query)), - 'is_sso': bool(re.match(r'^\d{9}$', query)), - 'is_servicenow': bool(re.match(r'^(GEINC|GECHG|GERIT|GESCT)\d+', query, re.IGNORECASE)), - 'servicenow_prefix': re.match(r'^(GEINC|GECHG|GERIT|GESCT)', query, re.IGNORECASE).group(1) if re.match(r'^(GEINC|GECHG|GERIT|GESCT)', query, re.IGNORECASE) 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 == 'equipment' and hasattr(asset, 'equipment') and asset.equipment: - plugin_id = asset.equipment.equipmentid - 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 - - url_map = { - 'equipment': f"/machines/{plugin_id}", - 'computer': f"/pcs/{plugin_id}", - 'network_device': f"/network/{plugin_id}", - 'printer': f"/printers/{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: - 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 Exception as e: - logger.error(f"KnowledgeBase search failed: {e}") - return results - - -def _search_employees(query, search_term): - """Search Employees in separate wjf_employees database.""" - results = [] - try: - import pymysql - emp_conn = pymysql.connect( - host='localhost', - user='root', - password='rootpassword', - database='wjf_employees', - cursorclass=pymysql.cursors.DictCursor - ) - - 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)) - employees = cur.fetchall() - - emp_conn.close() - - for emp in employees: - 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_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: - 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: - 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: - 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: - 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: - 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.utcnow() - 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 equipment/device type name.""" - results = [] - - # Equipment: vendor, model, equipmenttype - try: - from plugins.equipment.models import Equipment, EquipmentType - equipment_assets = db.session.query(Asset).join( - Equipment, Equipment.assetid == Asset.assetid - ).outerjoin( - Vendor, Equipment.vendorid == Vendor.vendorid - ).outerjoin( - Model, Equipment.modelnumberid == Model.modelnumberid - ).outerjoin( - EquipmentType, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid - ).options( - joinedload(Asset.assettype), - joinedload(Asset.location), - ).filter( - Asset.isactive == True, - db.or_( - Vendor.vendor.ilike(search_term), - Model.modelnumber.ilike(search_term), - EquipmentType.equipmenttype.ilike(search_term) - ) - ).limit(10).all() - - for asset in equipment_assets: - results.append(_get_asset_result(asset, query, 30)) - except ImportError: - pass - except Exception as e: - logger.error(f"Equipment vendor/model/type search failed: {e}") - - # Printers: vendor, model, printertype - try: - 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: - 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: - import pymysql - emp_conn = pymysql.connect( - host='localhost', - user='root', - password='rootpassword', - database='wjf_employees', - cursorclass=pymysql.cursors.DictCursor - ) - 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: - 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: - from plugins.computers.models import Computer - hostname_plugins.append(('computer', Computer, 'computerid', '/pcs')) - except ImportError: - pass - try: - from plugins.printers.models import Printer - hostname_plugins.append(('printer', Printer, 'printerid', '/printers')) - except ImportError: - pass - try: - 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' - }) - - classification = _classify_query(query) - - # ServiceNOW prefix detection - return redirect immediately - if classification['is_servicenow']: - from urllib.parse import quote - servicenow_url = 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_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) - - # 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) +"""Global search API endpoint with full search parity.""" + +import re +import ipaddress +import logging + +from datetime import datetime +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, KnowledgeBase, + Asset, AssetType, Communication, Vendor, Model +) +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') + +# ServiceNOW URL template +SERVICENOW_URL = ( + 'https://geit.service-now.com/now/nav/ui/search/' + '0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/' + 'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/' + 'back-button-label/IT4IT%20Homepage/search-context/now%2Fnav%2Fui' +) + + +def _classify_query(query): + """Analyze the query string to determine its nature.""" + return { + 'is_ip': bool(re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query)), + 'is_sso': bool(re.match(r'^\d{9}$', query)), + 'is_servicenow': bool(re.match(r'^(GEINC|GECHG|GERIT|GESCT)\d+', query, re.IGNORECASE)), + 'servicenow_prefix': re.match(r'^(GEINC|GECHG|GERIT|GESCT)', query, re.IGNORECASE).group(1) if re.match(r'^(GEINC|GECHG|GERIT|GESCT)', query, re.IGNORECASE) 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 == 'equipment' and hasattr(asset, 'equipment') and asset.equipment: + plugin_id = asset.equipment.equipmentid + 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 + + url_map = { + 'equipment': f"/machines/{plugin_id}", + 'computer': f"/pcs/{plugin_id}", + 'network_device': f"/network/{plugin_id}", + 'printer': f"/printers/{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: + 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 Exception as e: + logger.error(f"KnowledgeBase search failed: {e}") + return results + + +def _search_employees(query, search_term): + """Search Employees in separate wjf_employees database.""" + results = [] + try: + # Use the 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)) + employees = cur.fetchall() + + emp_conn.close() + + for emp in employees: + 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_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.utcnow() + 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 equipment/device type name.""" + results = [] + + # Equipment: vendor, model, equipmenttype + try: + _require_enabled('equipment') + from plugins.equipment.models import Equipment, EquipmentType + equipment_assets = db.session.query(Asset).join( + Equipment, Equipment.assetid == Asset.assetid + ).outerjoin( + Vendor, Equipment.vendorid == Vendor.vendorid + ).outerjoin( + Model, Equipment.modelnumberid == Model.modelnumberid + ).outerjoin( + EquipmentType, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid + ).options( + joinedload(Asset.assettype), + joinedload(Asset.location), + ).filter( + Asset.isactive == True, + db.or_( + Vendor.vendor.ilike(search_term), + Model.modelnumber.ilike(search_term), + EquipmentType.equipmenttype.ilike(search_term) + ) + ).limit(10).all() + + for asset in equipment_assets: + results.append(_get_asset_result(asset, query, 30)) + except ImportError: + pass + except Exception as e: + logger.error(f"Equipment 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' + }) + + classification = _classify_query(query) + + # ServiceNOW prefix detection - return redirect immediately + if classification['is_servicenow']: + from urllib.parse import quote + servicenow_url = 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_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) + + # 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 index 8d41844..5533f66 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -13,6 +13,32 @@ settings_bp = Blueprint('settings', __name__) 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 = '********' + +# 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 = ['equipment', 'computer', 'printer', 'network_device'] + + +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.""" @@ -42,7 +68,7 @@ def list_settings(): query = query.filter_by(category=category) settings = query.order_by(Setting.category, Setting.key).all() - return success_response([s.to_dict() for s in settings]) + return success_response([_serialize_setting(s) for s in settings]) @settings_bp.route('/', methods=['GET']) @@ -54,7 +80,7 @@ def get_setting(key: str): if not setting: return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404) - return success_response(setting.to_dict()) + return success_response(_serialize_setting(setting)) @settings_bp.route('/', methods=['PUT']) @@ -74,15 +100,21 @@ def update_setting(key: str): # Track old value for audit old_value = setting.value - # Convert value to string for storage 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 = 'password' in key or 'token' in key or 'secret' in key + is_sensitive = _is_secret(key) AuditLog.log('updated', 'Setting', entityname=key, changes={ 'value': { 'old': '***' if is_sensitive else old_value, @@ -93,7 +125,7 @@ def update_setting(key: str): db.session.commit() invalidate_settings_cache() - return success_response(setting.to_dict(), message='Setting updated') + return success_response(_serialize_setting(setting), message='Setting updated') @settings_bp.route('', methods=['POST']) @@ -129,11 +161,28 @@ def create_setting(): return success_response(setting.to_dict(), message='Setting created', http_code=201) -@settings_bp.route('/seed', methods=['POST']) -@jwt_required() -def seed_default_settings(): - """Seed default settings if they don't exist.""" - defaults = [ +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 + ] + + defaults = identifierdefaults + [ # Zabbix integration { 'key': 'zabbix_enabled', @@ -280,8 +329,15 @@ def seed_default_settings(): }, ] + return defaults + + +@settings_bp.route('/seed', methods=['POST']) +@jwt_required() +def seed_default_settings(): + """Seed default settings if they don't exist.""" created = 0 - for d in defaults: + for d in build_default_settings(): if not Setting.query.filter_by(key=d['key']).first(): setting = Setting(**d) db.session.add(setting) diff --git a/shopdb/core/api/statuses.py b/shopdb/core/api/statuses.py deleted file mode 100644 index bd8e46d..0000000 --- a/shopdb/core/api/statuses.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Machine Statuses 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 MachineStatus -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query - -statuses_bp = Blueprint('statuses', __name__) - - -@statuses_bp.route('', methods=['GET']) -@jwt_required(optional=True) -def list_statuses(): - """List all machine statuses.""" - page, per_page = get_pagination_params(request) - - query = MachineStatus.query - - if request.args.get('active', 'true').lower() != 'false': - query = query.filter(MachineStatus.isactive == True) - - query = query.order_by(MachineStatus.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) - - -@statuses_bp.route('/', methods=['GET']) -@jwt_required(optional=True) -def get_status(status_id: int): - """Get a single status.""" - s = MachineStatus.query.get(status_id) - - if not s: - return error_response( - ErrorCodes.NOT_FOUND, - f'Status with ID {status_id} not found', - http_code=404 - ) - - return success_response(s.to_dict()) - - -@statuses_bp.route('', methods=['POST']) -@jwt_required() -def create_status(): - """Create a new status.""" - data = request.get_json() - - if not data or not data.get('status'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'status is required') - - if MachineStatus.query.filter_by(status=data['status']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"Status '{data['status']}' already exists", - http_code=409 - ) - - s = MachineStatus( - 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='Status created', http_code=201) - - -@statuses_bp.route('/', methods=['PUT']) -@jwt_required() -def update_status(status_id: int): - """Update a status.""" - s = MachineStatus.query.get(status_id) - - if not s: - return error_response( - ErrorCodes.NOT_FOUND, - f'Status with ID {status_id} not found', - http_code=404 - ) - - data = request.get_json() - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - if 'status' in data and data['status'] != s.status: - if MachineStatus.query.filter_by(status=data['status']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"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='Status updated') - - -@statuses_bp.route('/', methods=['DELETE']) -@jwt_required() -def delete_status(status_id: int): - """Delete (deactivate) a status.""" - s = MachineStatus.query.get(status_id) - - if not s: - return error_response( - ErrorCodes.NOT_FOUND, - f'Status with ID {status_id} not found', - http_code=404 - ) - - from shopdb.core.models import Machine - if Machine.query.filter_by(statusid=status_id, isactive=True).first(): - return error_response( - ErrorCodes.CONFLICT, - 'Cannot delete status: machines are using it', - http_code=409 - ) - - s.isactive = False - db.session.commit() - - return success_response(message='Status deleted') diff --git a/shopdb/core/models/__init__.py b/shopdb/core/models/__init__.py index 472f867..4ffd337 100644 --- a/shopdb/core/models/__init__.py +++ b/shopdb/core/models/__init__.py @@ -2,16 +2,16 @@ from .base import BaseModel, SoftDeleteMixin, AuditMixin from .asset import Asset, AssetType, AssetStatus -from .machine import Machine, MachineType, MachineStatus, PCType +from .machine import MachineType from .vendor import Vendor from .model import Model from .businessunit import BusinessUnit -from .location import Location +from .location import Location, LocationType from .operatingsystem import OperatingSystem -from .relationship import MachineRelationship, AssetRelationship, RelationshipType +from .relationship import AssetRelationship, RelationshipType from .communication import Communication, CommunicationType from .user import User, Role, Permission -from .application import Application, AppVersion, AppOwner, SupportTeam, InstalledApp +from .application import Application, AppVersion, AppOwner, SupportTeam from .knowledgebase import KnowledgeBase from .setting import Setting from .auditlog import AuditLog @@ -25,19 +25,16 @@ __all__ = [ 'Asset', 'AssetType', 'AssetStatus', - # Machine (legacy) - 'Machine', + # Legacy machine type lookup (still referenced by models.machinetypeid) 'MachineType', - 'MachineStatus', - 'PCType', # Reference 'Vendor', 'Model', 'BusinessUnit', 'Location', + 'LocationType', 'OperatingSystem', # Relationships - 'MachineRelationship', 'AssetRelationship', 'RelationshipType', # Communication @@ -52,7 +49,6 @@ __all__ = [ 'AppVersion', 'AppOwner', 'SupportTeam', - 'InstalledApp', # Knowledge Base 'KnowledgeBase', # Settings diff --git a/shopdb/core/models/application.py b/shopdb/core/models/application.py index e2cc75e..f7f3b5f 100644 --- a/shopdb/core/models/application.py +++ b/shopdb/core/models/application.py @@ -1,143 +1,103 @@ -"""Application tracking models.""" - -from shopdb.extensions import db -from .base import BaseModel - - -class AppOwner(BaseModel): - """Application owner/contact.""" - __tablename__ = 'appowners' - - appownerid = db.Column(db.Integer, primary_key=True) - appowner = db.Column(db.String(100), nullable=False) - sso = db.Column(db.String(50)) - email = db.Column(db.String(100)) - - # Relationships - supportteams = db.relationship('SupportTeam', back_populates='owner', lazy='dynamic') - - def __repr__(self): - return f"" - - -class SupportTeam(BaseModel): - """Application support team.""" - __tablename__ = 'supportteams' - - supportteamid = db.Column(db.Integer, primary_key=True) - teamname = db.Column(db.String(100), nullable=False) - teamurl = db.Column(db.String(255)) - appownerid = db.Column(db.Integer, db.ForeignKey('appowners.appownerid')) - - # Relationships - owner = db.relationship('AppOwner', back_populates='supportteams') - applications = db.relationship('Application', back_populates='supportteam', lazy='dynamic') - - def __repr__(self): - return f"" - - -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) - image = db.Column(db.String(255)) - - # Relationships - supportteam = db.relationship('SupportTeam', back_populates='applications') - versions = db.relationship('AppVersion', back_populates='application', lazy='dynamic') - installed_on = db.relationship('InstalledApp', back_populates='application', lazy='dynamic') - - 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=db.func.now()) - isactive = db.Column(db.Boolean, default=True) - - # Relationships - application = db.relationship('Application', back_populates='versions') - installations = db.relationship('InstalledApp', back_populates='appversion', lazy='dynamic') - - # 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"" - - -class InstalledApp(db.Model): - """Junction table for applications installed on machines (PCs).""" - __tablename__ = 'installedapps' - - id = db.Column(db.Integer, primary_key=True) - machineid = db.Column(db.Integer, db.ForeignKey('machines.machineid'), nullable=False) - appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False) - appversionid = db.Column(db.Integer, db.ForeignKey('appversions.appversionid')) - isactive = db.Column(db.Boolean, default=True, nullable=False) - installeddate = db.Column(db.DateTime, default=db.func.now()) - - # Relationships - machine = db.relationship('Machine', back_populates='installedapps') - application = db.relationship('Application', back_populates='installed_on') - appversion = db.relationship('AppVersion', back_populates='installations') - - # Unique constraint - one app per machine (can have different versions over time) - __table_args__ = ( - db.UniqueConstraint('machineid', 'appid', name='uq_machine_app'), - ) - - def to_dict(self): - """Convert to dictionary.""" - return { - 'id': self.id, - 'machineid': self.machineid, - '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"" +"""Application tracking models.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class AppOwner(BaseModel): + """Application owner/contact.""" + __tablename__ = 'appowners' + + appownerid = db.Column(db.Integer, primary_key=True) + appowner = db.Column(db.String(100), nullable=False) + sso = db.Column(db.String(50)) + email = db.Column(db.String(100)) + + # Relationships + supportteams = db.relationship('SupportTeam', back_populates='owner', lazy='dynamic') + + def __repr__(self): + return f"" + + +class SupportTeam(BaseModel): + """Application support team.""" + __tablename__ = 'supportteams' + + supportteamid = db.Column(db.Integer, primary_key=True) + teamname = db.Column(db.String(100), nullable=False) + teamurl = db.Column(db.String(255)) + appownerid = db.Column(db.Integer, db.ForeignKey('appowners.appownerid')) + + # Relationships + owner = db.relationship('AppOwner', back_populates='supportteams') + applications = db.relationship('Application', back_populates='supportteam', lazy='dynamic') + + def __repr__(self): + return f"" + + +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 __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=db.func.now()) + 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 index c76cf79..4b82ac2 100644 --- a/shopdb/core/models/asset.py +++ b/shopdb/core/models/asset.py @@ -74,6 +74,17 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin): 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 equipment); 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, diff --git a/shopdb/core/models/communication.py b/shopdb/core/models/communication.py index bf95915..14b3d0c 100644 --- a/shopdb/core/models/communication.py +++ b/shopdb/core/models/communication.py @@ -36,14 +36,6 @@ class Communication(BaseModel): comment='FK to assets table (new architecture)' ) - # Legacy machine FK (for backward compatibility during migration) - machineid = db.Column( - db.Integer, - db.ForeignKey('machines.machineid'), - nullable=True, - comment='DEPRECATED: FK to machines table - use assetid instead' - ) - comtypeid = db.Column( db.Integer, db.ForeignKey('communicationtypes.comtypeid'), @@ -95,9 +87,8 @@ class Communication(BaseModel): __table_args__ = ( db.Index('idx_comm_asset', 'assetid'), - db.Index('idx_comm_machine', 'machineid'), db.Index('idx_comm_ip', 'ipaddress'), ) def __repr__(self): - return f"" + return f"" diff --git a/shopdb/core/models/location.py b/shopdb/core/models/location.py index 7d96bf9..480ce92 100644 --- a/shopdb/core/models/location.py +++ b/shopdb/core/models/location.py @@ -1,9 +1,25 @@ -"""Location model.""" +"""Location + LocationType models.""" from shopdb.extensions import db from .base import BaseModel +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) + + def __repr__(self): + return f"" + + class Location(BaseModel): """Physical location model.""" __tablename__ = 'locations' @@ -15,6 +31,19 @@ class Location(BaseModel): 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) @@ -26,5 +55,15 @@ class Location(BaseModel): 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]) + + 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 + return data + def __repr__(self): return f"" diff --git a/shopdb/core/models/machine.py b/shopdb/core/models/machine.py index f49c306..46a2ce2 100644 --- a/shopdb/core/models/machine.py +++ b/shopdb/core/models/machine.py @@ -1,7 +1,12 @@ -"""Unified Machine model - combines equipment and PCs.""" +"""Legacy machine type lookup. + +The Machine instance model and its PC/status lookups were retired (ADR-001); +assets are the platform contract. MachineType is kept only because the shared +`models` table still references it via models.machinetypeid. +""" from shopdb.extensions import db -from .base import BaseModel, SoftDeleteMixin, AuditMixin +from .base import BaseModel class MachineType(BaseModel): @@ -24,229 +29,3 @@ class MachineType(BaseModel): def __repr__(self): return f"" - - -class MachineStatus(BaseModel): - """Machine status options.""" - __tablename__ = 'machinestatuses' - - 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 PCType(BaseModel): - """ - PC type classification for more specific PC categorization. - Examples: Shopfloor PC, Engineer Workstation, CMM PC, etc. - """ - __tablename__ = 'pctypes' - - pctypeid = db.Column(db.Integer, primary_key=True) - pctype = db.Column(db.String(100), unique=True, nullable=False) - description = db.Column(db.Text) - - def __repr__(self): - return f"" - - -class Machine(BaseModel, SoftDeleteMixin, AuditMixin): - """ - Unified machine model for all asset types. - - Machine types can be: - - CNC machines, CMMs, EDMs, etc. (manufacturing equipment) - - PCs (shopfloor PCs, engineer workstations, etc.) - - Network devices (servers, switches, etc.) - if network_devices plugin not used - - The machinetype.category field distinguishes between types. - """ - __tablename__ = 'machines' - - machineid = db.Column(db.Integer, primary_key=True) - - # Identification - machinenumber = db.Column( - db.String(50), - unique=True, - nullable=False, - index=True, - comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)' - ) - alias = db.Column( - db.String(100), - comment='Friendly name' - ) - hostname = db.Column( - db.String(100), - index=True, - comment='Network hostname (for PCs)' - ) - serialnumber = db.Column( - db.String(100), - index=True, - comment='Hardware serial number' - ) - - # Classification - machinetypeid = db.Column( - db.Integer, - db.ForeignKey('machinetypes.machinetypeid'), - nullable=False - ) - pctypeid = db.Column( - db.Integer, - db.ForeignKey('pctypes.pctypeid'), - nullable=True, - comment='Set for PCs, NULL for equipment' - ) - businessunitid = db.Column( - db.Integer, - db.ForeignKey('businessunits.businessunitid'), - nullable=True - ) - modelnumberid = db.Column( - db.Integer, - db.ForeignKey('models.modelnumberid'), - nullable=True - ) - vendorid = db.Column( - db.Integer, - db.ForeignKey('vendors.vendorid'), - nullable=True - ) - - # Status - statusid = db.Column( - db.Integer, - db.ForeignKey('machinestatuses.statusid'), - default=1, - comment='In Use, Spare, Retired, etc.' - ) - - # Location and mapping - locationid = db.Column( - db.Integer, - db.ForeignKey('locations.locationid'), - nullable=True - ) - mapleft = db.Column(db.Integer, comment='X coordinate on floor map') - maptop = db.Column(db.Integer, comment='Y coordinate on floor map') - islocationonly = db.Column( - db.Boolean, - default=False, - comment='Virtual location marker (not actual machine)' - ) - - # PC-specific fields (nullable for non-PC machines) - osid = db.Column( - db.Integer, - db.ForeignKey('operatingsystems.osid'), - nullable=True - ) - loggedinuser = db.Column(db.String(100), nullable=True) - lastreporteddate = db.Column(db.DateTime, nullable=True) - lastboottime = db.Column(db.DateTime, nullable=True) - - # Features/flags - isvnc = db.Column(db.Boolean, default=False, comment='VNC remote access enabled') - iswinrm = db.Column(db.Boolean, default=False, comment='WinRM enabled') - isshopfloor = db.Column(db.Boolean, default=False, comment='Shopfloor PC') - requiresmanualconfig = db.Column( - db.Boolean, - default=False, - comment='Multi-PC machine needs manual configuration' - ) - - # Notes - notes = db.Column(db.Text, nullable=True) - - # Relationships - machinetype = db.relationship('MachineType', backref='machines') - pctype = db.relationship('PCType', backref='machines') - businessunit = db.relationship('BusinessUnit', backref='machines') - model = db.relationship('Model', backref='machines') - vendor = db.relationship('Vendor', backref='machines') - status = db.relationship('MachineStatus', backref='machines') - location = db.relationship('Location', backref='machines') - operatingsystem = db.relationship('OperatingSystem', backref='machines') - - # Communications (one-to-many) - communications = db.relationship( - 'Communication', - backref='machine', - cascade='all, delete-orphan', - lazy='dynamic' - ) - - # Installed applications (for PCs) - installedapps = db.relationship( - 'InstalledApp', - back_populates='machine', - cascade='all, delete-orphan', - lazy='dynamic' - ) - - # Indexes - __table_args__ = ( - db.Index('idx_machine_type_bu', 'machinetypeid', 'businessunitid'), - db.Index('idx_machine_location', 'locationid'), - db.Index('idx_machine_active', 'isactive'), - db.Index('idx_machine_hostname', 'hostname'), - ) - - def __repr__(self): - return f"" - - @property - def display_name(self): - """Get display name (alias if set, otherwise machinenumber).""" - return self.alias or self.machinenumber - - @property - def derived_machinetype(self): - """Get machinetype from model (single source of truth).""" - if self.model and self.model.machinetype: - return self.model.machinetype - return None - - @property - def is_pc(self): - """Check if this machine is a PC type.""" - mt = self.derived_machinetype - return mt.category == 'PC' if mt else False - - @property - def is_equipment(self): - """Check if this machine is equipment.""" - mt = self.derived_machinetype - return mt.category == 'Equipment' if mt else False - - @property - def is_network_device(self): - """Check if this machine is a network device.""" - mt = self.derived_machinetype - return mt.category == 'Network' if mt else False - - @property - def is_printer(self): - """Check if this machine is a printer.""" - mt = self.derived_machinetype - return mt.category == 'Printer' if mt else False - - @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 diff --git a/shopdb/core/models/relationship.py b/shopdb/core/models/relationship.py index 0c22c2d..3c199f8 100644 --- a/shopdb/core/models/relationship.py +++ b/shopdb/core/models/relationship.py @@ -115,59 +115,3 @@ class AssetRelationship(BaseModel): def __repr__(self): return f" {self.targetassetid}>" - - -class MachineRelationship(BaseModel): - """ - Relationships between machines. - - Examples: - - PC controls CNC machine - - Two CNCs are dualpath partners - """ - __tablename__ = 'machinerelationships' - - relationshipid = db.Column(db.Integer, primary_key=True) - - parentmachineid = db.Column( - db.Integer, - db.ForeignKey('machines.machineid'), - nullable=False - ) - childmachineid = db.Column( - db.Integer, - db.ForeignKey('machines.machineid'), - nullable=False - ) - relationshiptypeid = db.Column( - db.Integer, - db.ForeignKey('relationshiptypes.relationshiptypeid'), - nullable=False - ) - - notes = db.Column(db.Text) - - # Relationships - parent_machine = db.relationship( - 'Machine', - foreign_keys=[parentmachineid], - backref='child_relationships' - ) - child_machine = db.relationship( - 'Machine', - foreign_keys=[childmachineid], - backref='parent_relationships' - ) - relationship_type = db.relationship('RelationshipType', backref='relationships') - - __table_args__ = ( - db.UniqueConstraint( - 'parentmachineid', - 'childmachineid', - 'relationshiptypeid', - name='uq_machine_relationship' - ), - ) - - def __repr__(self): - return f" {self.childmachineid}>" diff --git a/shopdb/core/services/employee_service.py b/shopdb/core/services/employee_service.py deleted file mode 100644 index 0905f9c..0000000 --- a/shopdb/core/services/employee_service.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Employee lookup service - queries wjf_employees database.""" - -from typing import Optional, Dict, List -import pymysql -from flask import current_app - - -def get_employee_connection(): - """Get connection to wjf_employees database.""" - return pymysql.connect( - host='localhost', - user='root', - password='rootpassword', - database='wjf_employees', - cursorclass=pymysql.cursors.DictCursor - ) - - -def lookup_employee(sso: str) -> Optional[Dict]: - """ - Look up employee by SSO. - - Returns dict with: SSO, First_Name, Last_Name, full_name, Picture, etc. - """ - if not sso or not sso.strip().isdigit(): - return None - - try: - conn = get_employee_connection() - with conn.cursor() as cur: - cur.execute( - 'SELECT * FROM employees WHERE SSO = %s', - (int(sso.strip()),) - ) - row = cur.fetchone() - if row: - # Add computed full_name - first = (row.get('First_Name') or '').strip() - last = (row.get('Last_Name') or '').strip() - row['full_name'] = f"{first} {last}".strip() - return row - conn.close() - except Exception as e: - current_app.logger.error(f"Employee lookup error: {e}") - return None - - -def lookup_employees(sso_list: str) -> List[Dict]: - """ - Look up multiple employees by comma-separated SSO list. - - Returns list of employee dicts. - """ - if not sso_list: - return [] - - ssos = [s.strip() for s in sso_list.split(',') if s.strip().isdigit()] - if not ssos: - return [] - - try: - conn = get_employee_connection() - with conn.cursor() as cur: - placeholders = ','.join(['%s'] * len(ssos)) - cur.execute( - f'SELECT * FROM employees WHERE SSO IN ({placeholders})', - [int(s) for s in ssos] - ) - rows = cur.fetchall() - - # Add computed full_name to each - for row in rows: - first = (row.get('First_Name') or '').strip() - last = (row.get('Last_Name') or '').strip() - row['full_name'] = f"{first} {last}".strip() - - return rows - conn.close() - except Exception as e: - current_app.logger.error(f"Employee lookup error: {e}") - return [] - - -def get_employee_names(sso_list: str) -> str: - """ - Get comma-separated list of employee names from SSO list. - - Input: "212574611,212637451" - Output: "Brandon Saltz, Jon Kolkmann" - """ - employees = lookup_employees(sso_list) - if not employees: - return sso_list # Return SSOs as fallback - - return ', '.join(emp['full_name'] for emp in employees if emp.get('full_name')) - - -def get_employee_picture_url(sso: str) -> Optional[str]: - """Get URL to employee picture if available.""" - emp = lookup_employee(sso) - if emp and emp.get('Picture'): - # Pictures are stored relative paths like "Support/212574611.png" - return f"/static/employees/{emp['Picture']}" - return None diff --git a/shopdb/plugins/__init__.py b/shopdb/plugins/__init__.py index cf432c7..615e0d9 100644 --- a/shopdb/plugins/__init__.py +++ b/shopdb/plugins/__init__.py @@ -40,11 +40,18 @@ class PluginManager: 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) @@ -104,11 +111,18 @@ class PluginManager: # Register blueprint blueprint = plugin.get_blueprint() if blueprint: - self._app.register_blueprint( - blueprint, - url_prefix=plugin.meta.api_prefix - ) - logger.debug(f"Registered blueprint: {plugin.meta.api_prefix}") + 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(): @@ -160,17 +174,16 @@ class PluginManager: logger.warning(f"Plugin {name} is already installed") return False - # Load plugin class - plugin_class = self.loader.load_plugin_class(name) - if not plugin_class: + # 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 - - temp_plugin = plugin_class() - meta = temp_plugin.meta + manifest_version = manifest.get('version') # Check dependencies - for dep in meta.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" @@ -185,7 +198,7 @@ class PluginManager: return False # Register plugin - self.registry.register(name, meta.version) + self.registry.register(name, manifest_version) # Load the plugin plugin = self.loader.load_plugin(name, self._app, self._db) @@ -193,7 +206,7 @@ class PluginManager: self._register_plugin_components(plugin) plugin.on_install(self._app) - logger.info(f"Installed plugin: {name} v{meta.version}") + logger.info(f"Installed plugin: {name} v{manifest_version}") return True def uninstall_plugin(self, name: str, remove_data: bool = False) -> bool: @@ -246,22 +259,27 @@ class PluginManager: logger.info(f"Plugin {name} is already enabled") return True - # Check dependencies are enabled - plugin_class = self.loader.load_plugin_class(name) - if plugin_class: - temp = plugin_class() - for dep in temp.meta.dependencies: - if not self.registry.is_enabled(dep): - logger.error(f"Cannot enable {name}: {dep} is not enabled") - return False + # 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) - # Load the plugin - plugin = self.loader.load_plugin(name, self._app, self._db) - if plugin: - self._register_plugin_components(plugin) - plugin.on_enable(self._app) + # 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: + 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 @@ -299,6 +317,25 @@ class PluginManager: """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 index c73f8d5..a371736 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -35,7 +35,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { 'equipment': ('equipmenttypes', 'equipment'), 'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'), 'notifications': ('notificationtypes', 'notifications'), - 'printers': ('printertypes', 'printers', 'printerdata'), + 'printers': ('printertypes', 'printers', 'modelsupplies'), 'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'), } diff --git a/shopdb/plugins/base.py b/shopdb/plugins/base.py index 640f1c7..d1d215d 100644 --- a/shopdb/plugins/base.py +++ b/shopdb/plugins/base.py @@ -15,7 +15,8 @@ class PluginMeta: description: str author: str = "" dependencies: List[str] = field(default_factory=list) - core_version: str = ">=1.0.0" + # 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): @@ -119,6 +120,21 @@ class BasePlugin(ABC): """ 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 @@ -162,31 +178,3 @@ class BasePlugin(ABC): } """ return [] - - def get_searchable_fields(self) -> List[Dict]: - """ - Return fields this plugin contributes to global search. - - Each field: { - 'model': Type, # SQLAlchemy model class - 'field': str, # Column name to search - 'result_type': str, # Type identifier for search results - 'url_template': str, # URL template with {id} placeholder - 'title_field': str, # Field to use for result title - 'subtitle_field': str, # Optional field for subtitle - 'relevance_boost': int # Optional relevance score multiplier - } - - Example for equipment plugin: - return [{ - 'model': Equipment, - 'join_model': Asset, - 'join_condition': Equipment.assetid == Asset.assetid, - 'search_fields': ['assetnumber', 'name', 'serialnumber'], - 'result_type': 'equipment', - 'url_template': '/equipment/{id}', - 'title_field': 'assetnumber', - 'subtitle_field': 'name', - }] - """ - return [] diff --git a/shopdb/plugins/templates/api/routes.py.tmpl b/shopdb/plugins/templates/api/routes.py.tmpl index bfe0d28..7460022 100644 --- a/shopdb/plugins/templates/api/routes.py.tmpl +++ b/shopdb/plugins/templates/api/routes.py.tmpl @@ -3,13 +3,14 @@ from flask import Blueprint, request from flask_jwt_extended import jwt_required -from shopdb.utils.responses import ( +from shopdb.api import ( success_response, error_response, paginated_response, ErrorCodes, + get_pagination_params, + paginate_query, ) -from shopdb.utils.pagination import get_pagination_params, paginate_query from ..models import $Name diff --git a/shopdb/plugins/templates/models/model.py.tmpl b/shopdb/plugins/templates/models/model.py.tmpl index 5d3d42f..5669baf 100644 --- a/shopdb/plugins/templates/models/model.py.tmpl +++ b/shopdb/plugins/templates/models/model.py.tmpl @@ -6,8 +6,7 @@ this table holds the $name-specific fields. Replace the example fields below with your domain model. """ -from shopdb.extensions import db -from shopdb.core.models.base import BaseModel +from shopdb.api import db, BaseModel class $Name(BaseModel): diff --git a/shopdb/plugins/templates/plugin.py.tmpl b/shopdb/plugins/templates/plugin.py.tmpl index ceb474d..2daf77c 100644 --- a/shopdb/plugins/templates/plugin.py.tmpl +++ b/shopdb/plugins/templates/plugin.py.tmpl @@ -11,8 +11,7 @@ from typing import List, Dict, Optional, Type from flask import Flask, Blueprint from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.core.models import AssetType -from shopdb.extensions import db +from shopdb.api import db, AssetType from .models import $Name from .api import ${name}_bp diff --git a/shopdb/utils/employee_db.py b/shopdb/utils/employee_db.py new file mode 100644 index 0000000..7e6b7e9 --- /dev/null +++ b/shopdb/utils/employee_db.py @@ -0,0 +1,20 @@ +"""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 employee_connection(): + """Open a pymysql connection to the employee directory DB.""" + return pymysql.connect( + host=current_app.config['EMPLOYEE_DB_HOST'], + user=current_app.config['EMPLOYEE_DB_USER'], + password=current_app.config['EMPLOYEE_DB_PASSWORD'], + database=current_app.config['EMPLOYEE_DB_NAME'], + cursorclass=pymysql.cursors.DictCursor, + ) diff --git a/tests/test_core/__init__.py b/tests/test_core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_core/test_collector_contract.py b/tests/test_core/test_collector_contract.py new file mode 100644 index 0000000..257d885 --- /dev/null +++ b/tests/test_core/test_collector_contract.py @@ -0,0 +1,117 @@ +"""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 diff --git a/tests/test_core/test_dashboard_widgets.py b/tests/test_core/test_dashboard_widgets.py new file mode 100644 index 0000000..3bcdff4 --- /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_identifiers.py b/tests/test_core/test_identifiers.py new file mode 100644 index 0000000..48b06e4 --- /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_search_disabled.py b/tests/test_core/test_search_disabled.py new file mode 100644 index 0000000..fea12d9 --- /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_plugin_contract.py b/tests/test_plugin_contract.py index a4aa91c..41fd0c3 100644 --- a/tests/test_plugin_contract.py +++ b/tests/test_plugin_contract.py @@ -6,6 +6,7 @@ plugin's plugin.py / manifest.json. """ import json +import re from pathlib import Path import pytest @@ -125,12 +126,24 @@ def test_plugin_get_navigation_items_is_iterable(plugin_instances, name): assert isinstance(items, list) -@pytest.mark.parametrize('name', BUNDLED_PLUGINS) -def test_plugin_get_searchable_fields_is_iterable(plugin_instances, name): - """get_searchable_fields returns a list (default empty).""" - plugin = plugin_instances[name] - fields = plugin.get_searchable_fields() - assert isinstance(fields, 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_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(): @@ -143,3 +156,55 @@ def test_baseplugin_does_not_have_event_handlers_hook(): 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' + return [p for p in root.rglob('*.py') if '__pycache__' not in p.parts] + + +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) + ) diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index 418dd26..b48e3be 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -1,11 +1,10 @@ -"""Per-plugin Alembic chain wiring tests. +"""Plugin table-ownership tests. -Pins the bundled-plugin migration setup so a future refactor that breaks -the scaffolding fails fast with a clear test rather than a confusing -runtime error on a fresh deploy. +Bundled plugin schema is owned by the core migration chain (deploys run +`flask db upgrade` only, which reproduces the full schema). The per-plugin +Alembic helpers remain for external/filesystem plugins; these tests pin the +PLUGIN_TABLE_OWNERS registry those helpers consume. """ -from pathlib import Path - import pytest from shopdb.plugins.alembic_template import ( @@ -20,29 +19,16 @@ BUNDLED_PLUGINS = ('computers', 'equipment', 'network', 'notifications', 'printe @pytest.mark.parametrize('plugin', BUNDLED_PLUGINS) def test_bundled_plugin_has_table_owner_entry(plugin): """Every bundled plugin appears in PLUGIN_TABLE_OWNERS with at least - one table; otherwise its baseline migration would be a no-op.""" + 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', BUNDLED_PLUGINS) -def test_bundled_plugin_has_migrations_dir(plugin): - """Each bundled plugin has the on-disk Alembic scaffolding.""" - root = Path(__file__).resolve().parent.parent / 'plugins' / plugin / 'migrations' - assert (root / 'env.py').is_file(), f"{plugin}/migrations/env.py missing" - assert (root / 'alembic.ini').is_file(), f"{plugin}/migrations/alembic.ini missing" - assert (root / 'script.py.mako').is_file(), f"{plugin}/migrations/script.py.mako missing" - versions = root / 'versions' - assert versions.is_dir(), f"{plugin}/migrations/versions missing" - baseline = versions / '0001_baseline.py' - assert baseline.is_file(), f"{plugin}/migrations/versions/0001_baseline.py missing" - - @pytest.mark.parametrize('plugin', BUNDLED_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. Catches drift between the - template's table list and what the models declare.""" + registry and what the models declare.""" with app.app_context(): md = _get_plugin_metadata(plugin) owned = set(PLUGIN_TABLE_OWNERS[plugin]) diff --git a/tests/test_plugins/__init__.py b/tests/test_plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_plugins/test_modelsupplies.py b/tests/test_plugins/test_modelsupplies.py new file mode 100644 index 0000000..f83d6e7 --- /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_zabbix_live.py b/tests/test_plugins/test_zabbix_live.py new file mode 100644 index 0000000..5f87f24 --- /dev/null +++ b/tests/test_plugins/test_zabbix_live.py @@ -0,0 +1,142 @@ +"""End-to-end test of ZabbixService against the mock Zabbix JSON-RPC server. + +Exercises the real HTTP path: Bearer auth, host.get by IP, tag-filtered +item.get, supply parsing, ping, and the low-supplies roll-up. +""" + +import socket + +import pytest + +from tools.mock_zabbix import serve_in_thread + + +def _free_port(): + sock = socket.socket() + sock.bind(('127.0.0.1', 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +@pytest.fixture +def mock_zabbix(app): + """Boot the mock server and point app config at it.""" + port = _free_port() + server = serve_in_thread(port, 'testtoken') + app.config['ZABBIX_ENABLED'] = True + app.config['ZABBIX_URL'] = f'http://127.0.0.1:{port}' + app.config['ZABBIX_TOKEN'] = 'testtoken' + yield + server.shutdown() + + +def test_service_reachable_and_configured(app, db, mock_zabbix): + from plugins.printers.services import ZabbixService + with app.app_context(): + service = ZabbixService() + assert service.isconfigured + assert service.isreachable + + +def test_gethostid_by_ip(app, db, mock_zabbix): + from plugins.printers.services import ZabbixService + with app.app_context(): + service = ZabbixService() + assert service.gethostidbyip('10.20.30.40') == '10501' + assert service.gethostidbyip('1.2.3.4') is None + + +def test_supplies_parsed_with_color_and_status_filter(app, db, mock_zabbix): + from plugins.printers.services import ZabbixService + with app.app_context(): + supplies = ZabbixService().getsuppliesbyip('10.20.30.40') + # disabled item (status=1) dropped, three active remain + names = {s['name'] for s in supplies} + assert 'Disabled Drum Level' not in names + assert len(supplies) == 3 + black = next(s for s in supplies if s['name'].startswith('Black')) + assert black['color'] == 'black' + assert black['level'] == 4 + + +def test_ping_status(app, db, mock_zabbix): + from plugins.printers.services import ZabbixService + with app.app_context(): + assert ZabbixService().getpingstatus('10.20.30.40') == '1' + + +def test_bad_token_returns_no_data(app, db): + """Wrong token -> API errors -> service returns nothing, fails soft.""" + port = _free_port() + server = serve_in_thread(port, 'rightsecret') + app.config['ZABBIX_ENABLED'] = True + app.config['ZABBIX_URL'] = f'http://127.0.0.1:{port}' + app.config['ZABBIX_TOKEN'] = 'wrongsecret' + try: + with app.app_context(): + assert ZabbixService_gethost(app) is None + finally: + server.shutdown() + + +def ZabbixService_gethost(app): + from plugins.printers.services import ZabbixService + return ZabbixService().gethostidbyip('10.20.30.40') + + +def test_low_supplies_rollup_flags_waste_and_toner(app, db, mock_zabbix): + """The mock host has a 4% black toner and a 97%-full waste -> both flagged.""" + from shopdb.core.models import ( + Vendor, Model, Asset, AssetType, Communication, CommunicationType + ) + from plugins.printers.models import Printer + from plugins.printers.api.asset_routes import _get_low_supplies_data + from shopdb.extensions import cache + + with app.app_context(): + # an HP printer at the mock's known IP + vendor = Vendor(vendor='HP') + db.session.add(vendor) + db.session.flush() + model = Model(modelnumber='HP M454', vendorid=vendor.vendorid) + db.session.add(model) + + atype = AssetType.query.filter_by(assettype='printer').first() + if not atype: + atype = AssetType(assettype='printer', pluginname='printers', + tablename='printers') + db.session.add(atype) + db.session.flush() + asset = Asset(assetnumber='PRN-1', name='Test Printer', + assettypeid=atype.assettypeid) + db.session.add(asset) + db.session.flush() + + printer = Printer(assetid=asset.assetid, vendorid=vendor.vendorid, + modelnumberid=model.modelnumberid) + db.session.add(printer) + + comtype = CommunicationType.query.filter_by(comtype='IP').first() + if not comtype: + comtype = CommunicationType(comtype='IP') + db.session.add(comtype) + db.session.flush() + db.session.add(Communication(assetid=asset.assetid, + comtypeid=comtype.comtypeid, + ipaddress='10.20.30.40', isprimary=True)) + db.session.commit() + + cache.delete('printers_low_supplies') + data = _get_low_supplies_data() + + assert data['summary']['total_checked'] == 1 + assert len(data['printers']) == 1 + row = data['printers'][0] + statuses = {s['name']: s['status'] for s in row['supplies']} + # 4% black toner is critical + assert statuses['Black Toner Level'] == 'critical' + # 97%-full waste (HP, non-inverted) -> 3% remaining -> critical + assert statuses['Waste Cartridge Level'] == 'critical' + # 60% cyan is fine + assert statuses['Cyan Toner Level'] == 'ok' diff --git a/tests/test_security_config.py b/tests/test_security_config.py index 2f35aff..0cd1e17 100644 --- a/tests/test_security_config.py +++ b/tests/test_security_config.py @@ -68,3 +68,23 @@ def test_production_validate_passes_with_complete_config(clean_env): 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/tools/docker-compose.zabbix.yml b/tools/docker-compose.zabbix.yml new file mode 100644 index 0000000..d1bfcec --- /dev/null +++ b/tools/docker-compose.zabbix.yml @@ -0,0 +1,61 @@ +# Real Zabbix 7.0 server for full integration testing. +# +# docker compose -f tools/docker-compose.zabbix.yml up -d +# +# Web UI: http://localhost:8888 (default login Admin / zabbix) +# API: http://localhost:8888/api_jsonrpc.php +# +# After it is up: +# 1. Log in, go to Users > API tokens, create a token, copy it. +# 2. Data collection > Hosts > Create host. Name the host by its IP +# (e.g. 10.20.30.40) so host.get filter {host:[ip]} finds it, the same +# way the production shop Zabbix names printer hosts. +# 3. Add items tagged component=supplies, type=level, color= to +# mirror the real printer templates (the mock server documents the shape). +# 4. Point the app at it: +# ZABBIX_ENABLED=true +# ZABBIX_URL=http://localhost:8888 +# ZABBIX_TOKEN= +# or set zabbix_enabled / zabbix_url / zabbix_token in the settings table. +# +# For API-contract testing only, prefer tools/mock_zabbix.py - it needs no +# image pulls and returns ready-made tagged supply items. + +services: + zabbix-postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: zabbix + POSTGRES_PASSWORD: zabbixpass + POSTGRES_DB: zabbix + volumes: + - zabbix-pgdata:/var/lib/postgresql/data + + zabbix-server: + image: zabbix/zabbix-server-pgsql:alpine-7.0-latest + environment: + DB_SERVER_HOST: zabbix-postgres + POSTGRES_USER: zabbix + POSTGRES_PASSWORD: zabbixpass + POSTGRES_DB: zabbix + depends_on: + - zabbix-postgres + ports: + - "10051:10051" + + zabbix-web: + image: zabbix/zabbix-web-nginx-pgsql:alpine-7.0-latest + environment: + DB_SERVER_HOST: zabbix-postgres + POSTGRES_USER: zabbix + POSTGRES_PASSWORD: zabbixpass + POSTGRES_DB: zabbix + ZBX_SERVER_HOST: zabbix-server + PHP_TZ: America/New_York + depends_on: + - zabbix-server + ports: + - "8888:8080" + +volumes: + zabbix-pgdata: diff --git a/tools/mock_zabbix.py b/tools/mock_zabbix.py new file mode 100644 index 0000000..de4e1ac --- /dev/null +++ b/tools/mock_zabbix.py @@ -0,0 +1,144 @@ +"""Mock Zabbix 7.0 JSON-RPC server for testing the printer supply integration. + +Speaks just enough of the Zabbix API (api_jsonrpc.php) to exercise our +ZabbixService end to end over real HTTP: Bearer token auth, host.get by IP, +and item.get returning printer supply items tagged the way the real Zabbix +templates tag them (component=supplies, type=level, color=). + +Run standalone: + python tools/mock_zabbix.py --port 18080 --token testtoken + +Then point the app at it: + ZABBIX_ENABLED=true + ZABBIX_URL=http://localhost:18080 + ZABBIX_TOKEN=testtoken + +It is also imported by tests/test_plugins/test_zabbix_live.py, which boots it +in a background thread. +""" + +import argparse +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +# one fake host, named by its IP (matches how the real shop Zabbix names hosts) +HOSTS = { + '10.20.30.40': '10501', +} + +# supply level items for host 10501, with Zabbix-style tags +SUPPLY_ITEMS = { + '10501': [ + {'itemid': '1', 'name': 'Black Toner Level', 'lastvalue': '4', + 'status': '0', 'state': '0', + 'tags': [{'tag': 'component', 'value': 'supplies'}, + {'tag': 'type', 'value': 'level'}, + {'tag': 'color', 'value': 'black'}]}, + {'itemid': '2', 'name': 'Cyan Toner Level', 'lastvalue': '60', + 'status': '0', 'state': '0', + 'tags': [{'tag': 'component', 'value': 'supplies'}, + {'tag': 'type', 'value': 'level'}, + {'tag': 'color', 'value': 'cyan'}]}, + {'itemid': '3', 'name': 'Waste Cartridge Level', 'lastvalue': '97', + 'status': '0', 'state': '0', + 'tags': [{'tag': 'component', 'value': 'supplies'}, + {'tag': 'type', 'value': 'level'}]}, + {'itemid': '4', 'name': 'Disabled Drum Level', 'lastvalue': '0', + 'status': '1', 'state': '0', + 'tags': [{'tag': 'component', 'value': 'supplies'}, + {'tag': 'type', 'value': 'level'}]}, + ], +} + +PING_ITEMS = { + '10501': '1', +} + + +def build_handler(token): + class ZabbixHandler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass # quiet + + def _send(self, payload, code=200): + body = json.dumps(payload).encode() + self.send_response(code) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + # reachability probe hits the endpoint with GET + self._send({'jsonrpc': '2.0', 'error': {'code': -32600}, 'id': None}) + + def do_POST(self): + length = int(self.headers.get('Content-Length', 0)) + request = json.loads(self.rfile.read(length) or b'{}') + method = request.get('method') + params = request.get('params', {}) + reqid = request.get('id', 1) + + auth = self.headers.get('Authorization', '') + if auth != f'Bearer {token}': + self._send({'jsonrpc': '2.0', + 'error': {'code': -32602, 'message': 'Not authorised'}, + 'id': reqid}) + return + + result = self._dispatch(method, params) + self._send({'jsonrpc': '2.0', 'result': result, 'id': reqid}) + + def _dispatch(self, method, params): + if method in ('apiinfo.version',): + return '7.0.0' + if method == 'hostgroup.get': + return [{'groupid': '1'}] + if method == 'host.get': + ips = (params.get('filter') or {}).get('host', []) + out = [] + for ip in ips: + if ip in HOSTS: + out.append({'hostid': HOSTS[ip], 'host': ip, 'name': ip}) + return out + if method == 'item.get': + hostids = params.get('hostids') + hostid = hostids[0] if isinstance(hostids, list) else hostids + search = params.get('search') or {} + if 'icmpping' in (search.get('key_') or ''): + value = PING_ITEMS.get(hostid) + return [{'lastvalue': value}] if value is not None else [] + return SUPPLY_ITEMS.get(hostid, []) + return [] + + return ZabbixHandler + + +def serve(port, token): + server = ThreadingHTTPServer(('127.0.0.1', port), build_handler(token)) + return server + + +def serve_in_thread(port, token): + """Start the mock in a daemon thread. Returns the server (call shutdown()).""" + server = serve(port, token) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Mock Zabbix JSON-RPC server') + parser.add_argument('--port', type=int, default=18080) + parser.add_argument('--token', default='testtoken') + args = parser.parse_args() + httpd = serve(args.port, args.token) + print(f"Mock Zabbix on http://127.0.0.1:{args.port}/api_jsonrpc.php " + f"(token: {args.token})") + print(f"Known host: {list(HOSTS)[0]} -> supplies + icmpping") + try: + httpd.serve_forever() + except KeyboardInterrupt: + httpd.shutdown() diff --git a/tools/setup_zabbix_fixture.py b/tools/setup_zabbix_fixture.py new file mode 100644 index 0000000..929c913 --- /dev/null +++ b/tools/setup_zabbix_fixture.py @@ -0,0 +1,140 @@ +"""Provision the local docker Zabbix (tools/docker-compose.zabbix.yml) with a +printer host shaped like the real shop Zabbix, so ZabbixService can be tested +against a live server instead of the mock. + +Creates: + - an API token (printed at the end; put it in .env as ZABBIX_TOKEN) + - host group "Printers" + - host named by IP "10.20.30.40" with an SNMP-less agent interface + - trapper items tagged component=supplies, type=level, color= + - an icmpping item + +Item values are pushed afterwards with zabbix_sender (see seed_values()). +Run: python tools/setup_zabbix_fixture.py +""" + +import sys +import requests + +BASE = "http://localhost:8888/api_jsonrpc.php" +HOST_IP = "10.20.30.40" +ADMIN_USER = "Admin" +ADMIN_PASS = "zabbix" + +# name, color tag, key (must be unique per host) +SUPPLY_ITEMS = [ + ("Black Toner Level", "black", "supply.black"), + ("Cyan Toner Level", "cyan", "supply.cyan"), + ("Magenta Toner Level", "magenta", "supply.magenta"), + ("Yellow Toner Level", "yellow", "supply.yellow"), + ("Waste Cartridge Level", "", "supply.waste"), +] + + +def call(method, params, auth=None): + headers = {"Content-Type": "application/json-rpc"} + if auth: + headers["Authorization"] = f"Bearer {auth}" + resp = requests.post( + BASE, + json={"jsonrpc": "2.0", "method": method, "params": params, "id": 1}, + headers=headers, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + if "error" in data: + raise RuntimeError(f"{method}: {data['error']}") + return data["result"] + + +def login(): + # user.login returns a session token; usable as Bearer in 7.0 + return call("user.login", {"username": ADMIN_USER, "password": ADMIN_PASS}) + + +def make_api_token(sess): + # a real, persistent API token (survives logout, matches prod usage) + existing = call("token.get", {"filter": {"name": "shopdb-flask-test"}}, sess) + if existing: + tokenid = existing[0]["tokenid"] + else: + created = call("token.create", { + "name": "shopdb-flask-test", + "userid": call("user.get", {"output": ["userid"], + "filter": {"username": ADMIN_USER}}, sess)[0]["userid"], + }, sess) + tokenid = created["tokenids"][0] + return call("token.generate", [tokenid], sess)[0]["token"] + + +def ensure_hostgroup(sess): + found = call("hostgroup.get", {"filter": {"name": "Printers"}}, sess) + if found: + return found[0]["groupid"] + return call("hostgroup.create", {"name": "Printers"}, sess)["groupids"][0] + + +def ensure_host(sess, groupid): + found = call("host.get", {"filter": {"host": [HOST_IP]}, "output": ["hostid"]}, sess) + if found: + hostid = found[0]["hostid"] + # wipe existing items so re-runs are clean + items = call("item.get", {"hostids": hostid, "output": ["itemid"]}, sess) + if items: + call("item.delete", [i["itemid"] for i in items], sess) + return hostid + created = call("host.create", { + "host": HOST_IP, + "groups": [{"groupid": groupid}], + "interfaces": [{ + "type": 1, "main": 1, "useip": 1, + "ip": HOST_IP, "dns": "", "port": "10050", + }], + }, sess) + return created["hostids"][0] + + +def create_items(sess, hostid): + for name, color, key in SUPPLY_ITEMS: + tags = [ + {"tag": "component", "value": "supplies"}, + {"tag": "type", "value": "level"}, + ] + if color: + tags.append({"tag": "color", "value": color}) + call("item.create", { + "name": name, + "key_": key, + "hostid": hostid, + "type": 2, # Zabbix trapper, lets zabbix_sender push values + "value_type": 3, # unsigned int + "tags": tags, + }, sess) + # ping item, untagged, key icmpping + call("item.create", { + "name": "ICMP ping", + "key_": "icmpping", + "hostid": hostid, + "type": 2, + "value_type": 3, + }, sess) + + +def main(): + sess = login() + token = make_api_token(sess) + groupid = ensure_hostgroup(sess) + hostid = ensure_host(sess, groupid) + create_items(sess, hostid) + print("OK") + print(f"hostid={hostid}") + print(f"ZABBIX_TOKEN={token}") + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(f"FAILED: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/tools/shot.py b/tools/shot.py new file mode 100644 index 0000000..0766fd3 --- /dev/null +++ b/tools/shot.py @@ -0,0 +1,55 @@ +"""Headless-Chromium screenshot helper for the dev UI. + +Logs in once via the API, injects the token into localStorage the same way +the auth store does, then screenshots each path passed on the command line. + + venv/bin/python tools/shot.py /printers/1 /reports/toner /printers/1/edit + +Images land in the scratchpad dir as shot_.png. +""" + +import sys +import json +import urllib.request + +from playwright.sync_api import sync_playwright + +UI = "http://localhost:5173" +API = "http://localhost:5001/api" +USERNAME = "270015376" +PASSWORD = "changeme" +OUTDIR = "/tmp/claude-1000/-home-camp-projects/effc3424-ed5e-4b09-b83e-d141bee23c42/scratchpad" + + +def login(): + body = json.dumps({"username": USERNAME, "password": PASSWORD}).encode() + loginrequest = urllib.request.Request(f"{API}/auth/login", data=body, + headers={"Content-Type": "application/json"}) + data = json.load(urllib.request.urlopen(loginrequest))["data"] + return data["access_token"], data["refresh_token"], data["user"] + + +def main(paths): + token, refresh, user = login() + seed = f""" + localStorage.setItem('token', {json.dumps(token)}); + localStorage.setItem('refreshToken', {json.dumps(refresh)}); + localStorage.setItem('user', {json.dumps(json.dumps(user))}); + """ + with sync_playwright() as p: + browser = p.chromium.launch() + context = browser.new_context(viewport={"width": 1400, "height": 1000}) + context.add_init_script(seed) + page = context.new_page() + for path in paths: + page.goto(f"{UI}{path}", wait_until="networkidle", timeout=30000) + page.wait_for_timeout(1200) # let supply fetch + render settle + name = "shot_" + (path.strip("/").replace("/", "_") or "home") + ".png" + out = f"{OUTDIR}/{name}" + page.screenshot(path=out, full_page=True) + print(out) + browser.close() + + +if __name__ == "__main__": + main(sys.argv[1:] or ["/printers/1"])