Merge feat/asset-identifiers-statuses-printertypes

32 commits: Zabbix supply backend, per-asset-type identifiers, full Machine-model
retirement, locationtypes (ADR-001), ADR-006 collector contract, plugin contract
purity (shopdb.api import surface), dashboard-widgets hook consumer, removal of the
dead get_searchable_fields hook, QR/USB label print fixes, and two rounds of
skill-driven review fixes (security: no hardcoded creds; fail-loud hooks; manifest
-driven loader; decoupled core from plugin models).

154 tests pass, naming/style green. __contract_version__ 0.4.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 20:21:55 -04:00
149 changed files with 10241 additions and 8554 deletions

View File

@@ -58,3 +58,12 @@ ZABBIX_TOKEN=
# COLLECTOR_API_KEY_<PLUGINNAME> first, then COLLECTOR_API_KEY as fallback. # COLLECTOR_API_KEY_<PLUGINNAME> first, then COLLECTOR_API_KEY as fallback.
# COLLECTOR_API_KEY= # COLLECTOR_API_KEY=
# COLLECTOR_API_KEY_COMPUTERS= # 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

View File

@@ -32,6 +32,9 @@ Edit `.env`:
| `API_PORT` | No | Default 5001 | | `API_PORT` | No | Default 5001 |
| `LOG_LEVEL` | No | Default INFO | | `LOG_LEVEL` | No | Default INFO |
| `ZABBIX_URL`, `ZABBIX_TOKEN` | No | Only if printers plugin uses Zabbix | | `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_<PLUGIN>` | 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 ## Step 2: Bring up the stack

View File

@@ -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`: The framework declares its contract version in `shopdb/__init__.py`:
```python ```python
__contract_version__ = '0.2.0' __contract_version__ = '0.4.0'
``` ```
Each plugin's `manifest.json` declares the range of contract versions it supports: 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]` ### `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('<name>')`, which searches enabled plugins and
returns the registered class/factory (or None).
```python ```python
from .services import ZabbixService 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]` ### `get_navigation_items() -> List[Dict]`
Returns navigation menu items. Returns navigation menu items.
@@ -181,24 +187,10 @@ class ComputersPlugin(BasePlugin):
}] }]
``` ```
### `get_searchable_fields() -> List[Dict]` > Removed in contract 0.4.0: `get_searchable_fields`. Global search
> (`/api/search`) is a core concern that queries the asset model directly and
Declares fields the plugin contributes to global search. > already covers every bundled asset type; no plugin ever implemented the hook.
> Search honors runtime plugin enable/disable.
```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',
}]
```
### `get_collector_schema() -> Optional[Dict]` ### `get_collector_schema() -> Optional[Dict]`
@@ -222,6 +214,31 @@ class ComputersPlugin(BasePlugin):
If the hook returns `None` (the default), no collector endpoint is registered. 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/<pluginname>` 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 ## Lifecycle hooks
These run when the plugin's installation state changes. All optional. 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_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 | | `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 ## Helpers exposed to plugins
The framework provides helper APIs in `shopdb.api` (the public namespace). The framework provides helper APIs in `shopdb.api` (the public namespace).

View File

@@ -119,10 +119,9 @@ Override hooks on the plugin class as needed. See [PLUGIN-HOOKS.md](PLUGIN-HOOKS
| Hook | Adds | | Hook | Adds |
|------|------| |------|------|
| `get_searchable_fields` | Plugin contributes to the global search endpoint |
| `get_navigation_items` | Plugin shows up in the sidebar nav | | `get_navigation_items` | Plugin shows up in the sidebar nav |
| `get_dashboard_widgets` | Plugin's dashboard widget appears on the home page | | `get_dashboard_widgets` | Plugin's dashboard widget appears on the home page |
| `get_collector_schema` | Plugin accepts external pushes at `/api/collector/<name>` | | `get_collector_schema` + `apply_collector_payload` | Plugin accepts external pushes at `/api/collector/<name>` |
Each hook has a default that does nothing. Override only what your plugin needs. Each hook has a default that does nothing. Override only what your plugin needs.

View File

@@ -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 - `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 - `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 #### 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 #### Excluded from the contract for v1

View File

@@ -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. 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. 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 ## 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. - 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.

View File

@@ -15,7 +15,7 @@ This calls for a generalizable contract: any plugin that wants to accept externa
## Decision ## 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 ```python
def get_collector_schema(self) -> Optional[dict]: 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. - 'fields': JSON Schema definitions for the rest of the payload.
""" """
return None return None
def apply_collector_payload(self, payload: dict) -> dict:
"""Idempotently upsert an asset from a validated collector payload.
Called by /api/collector/<pluginname> 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/<pluginname>` 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/<pluginname>` 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_<PLUGINNAME>` (preferred, plugin-specific) - `COLLECTOR_API_KEY_<PLUGINNAME>` (preferred, plugin-specific)
- `COLLECTOR_API_KEY` (fallback, shared) - `COLLECTOR_API_KEY` (fallback, shared)
@@ -125,7 +142,7 @@ Migration path:
## References ## References
- `shopdb/core/api/collector.py` (legacy endpoint to be removed) - `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-001 (asset model the collectors target)
- ADR-002 (collector schema is part of plugin contract; changes to the hook signature are major bumps) - 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 - The PXE project (`/home/camp/projects/pxe/`) which feeds the computers collector

View File

@@ -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) // Equipment API (plugin)
export const equipmentApi = { export const equipmentApi = {
list(params = {}) { list(params = {}) {
@@ -176,10 +144,10 @@ export const computersApi = {
// Relationship Types API // Relationship Types API
export const relationshipTypesApi = { export const relationshipTypesApi = {
list() { list() {
return api.get('/machines/relationshiptypes') return api.get('/assets/relationshiptypes')
}, },
create(data) { 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 // Vendors API
export const vendorsApi = { export const vendorsApi = {
list(params = {}) { list(params = {}) {
@@ -253,6 +202,11 @@ export const locationsApi = {
}, },
delete(id) { delete(id) {
return api.delete(`/locations/${id}`) return api.delete(`/locations/${id}`)
},
types: {
list() {
return api.get('/locations/types')
}
} }
} }
@@ -264,9 +218,26 @@ export const printersApi = {
get(id) { get(id) {
return api.get(`/printers/${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) { updateExtension(id, data) {
return api.put(`/printers/${id}/printerdata`, 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) { updateCommunication(id, data) {
return api.put(`/printers/${id}/communication`, data) return api.put(`/printers/${id}/communication`, data)
}, },
@@ -279,6 +250,12 @@ export const printersApi = {
lowSupplies() { lowSupplies() {
return api.get('/printers/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() { dashboardSummary() {
return api.get('/printers/dashboard/summary') return api.get('/printers/dashboard/summary')
}, },
@@ -303,6 +280,27 @@ export const printersApi = {
create(data) { create(data) {
return api.post('/printers/supplytypes', 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 = {}) { list(params = {}) {
return api.get('/models', { 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) { get(id) {
return api.get(`/models/${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 // Operating Systems API
export const operatingsystemsApi = { export const operatingsystemsApi = {
list(params = {}) { list(params = {}) {
@@ -528,8 +524,17 @@ export const assetsApi = {
} }
}, },
statuses: { statuses: {
list() { list(params = {}) {
return api.get('/assets/statuses') 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 export const businessUnitsApi = businessunitsApi
// System Settings API // System Settings API
export const pluginsApi = {
list() {
return api.get('/plugins')
},
setEnabled(name, enabled) {
return api.put(`/plugins/${name}`, { enabled })
}
}
export const settingsApi = { export const settingsApi = {
list(params = {}) { list(params = {}) {
return api.get('/settings', { params }) return api.get('/settings', { params })

View File

@@ -372,6 +372,15 @@ th, td {
border-top: 1px solid var(--border); 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 { th {
font-weight: 600; font-weight: 600;
font-size: 11px; font-size: 11px;

View File

@@ -0,0 +1,71 @@
// Per-type enable/disable flags for optional asset identifiers, read from the
// settings table. Keys follow identifier_<name>_<assettype>_enabled. A legacy
// global key identifier_<name>_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 }
}

View File

@@ -92,6 +92,12 @@ export default [
component: () => import('../../views/settings/SystemSettings.vue'), component: () => import('../../views/settings/SystemSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true } meta: { requiresAuth: true, requiresAdmin: true }
}, },
{
path: 'settings/plugins',
name: 'plugins',
component: () => import('../../views/settings/PluginsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{ {
path: 'settings/auditlogs', path: 'settings/auditlogs',
name: 'audit-logs', name: 'audit-logs',

View File

@@ -23,5 +23,12 @@ export default [
name: 'printer-edit', name: 'printer-edit',
component: () => import('../../views/printers/PrinterForm.vue'), component: () => import('../../views/printers/PrinterForm.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true }
},
// printer-specific settings
{
path: 'settings/modelsupplies',
name: 'model-supplies',
component: () => import('../../views/settings/ModelSuppliesList.vue'),
meta: { requiresAuth: true }
} }
] ]

View File

@@ -65,18 +65,18 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Asset #</th>
<th>Category</th>
<th>Type</th> <th>Type</th>
<th>Status</th>
<th>Business Unit</th> <th>Business Unit</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="machine in recentMachines" :key="machine.machineid"> <tr v-for="machine in recentMachines" :key="machine.assetid">
<td>{{ machine.machinenumber || machine.hostname || machine.alias || '-' }}</td> <td>{{ machine.assetnumber || machine.name || '-' }}</td>
<td>{{ machine.category || '-' }}</td> <td>{{ machine.assettypename || '-' }}</td>
<td>{{ machine.machinetype || '-' }}</td> <td>{{ machine.statusname || '-' }}</td>
<td>{{ machine.businessunit || '-' }}</td> <td>{{ machine.businessunitname || '-' }}</td>
</tr> </tr>
<tr v-if="recentMachines.length === 0"> <tr v-if="recentMachines.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);"> <td colspan="4" style="text-align: center; color: var(--text-light);">
@@ -93,7 +93,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { dashboardApi, machinesApi, printersApi } from '../api' import { dashboardApi, assetsApi, printersApi } from '../api'
const loading = ref(true) const loading = ref(true)
const stats = ref({}) const stats = ref({})
@@ -104,7 +104,7 @@ onMounted(async () => {
try { try {
const [dashRes, machinesRes, printersRes] = await Promise.all([ const [dashRes, machinesRes, printersRes] = await Promise.all([
dashboardApi.summary().catch(() => ({ data: { data: {} } })), dashboardApi.summary().catch(() => ({ data: { data: {} } })),
machinesApi.list({ perpage: 5 }).catch(() => ({ data: { data: [] } })), assetsApi.list({ perpage: 5 }).catch(() => ({ data: { data: [] } })),
printersApi.dashboardSummary().catch(() => ({ data: { data: {} } })) printersApi.dashboardSummary().catch(() => ({ data: { data: {} } }))
]) ])

View File

@@ -100,12 +100,12 @@
<router-link <router-link
v-for="install in installedOn" v-for="install in installedOn"
:key="install.id" :key="install.id"
:to="`/pcs/${install.machineid}`" :to="`/pcs/${install.computerid}`"
class="pc-item" class="pc-item"
> >
<div class="pc-info"> <div class="pc-info">
<span class="pc-name">{{ install.machine?.machinenumber || `PC #${install.machineid}` }}</span> <span class="pc-name">{{ install.computer?.hostname || install.computer?.assetnumber || `PC #${install.computerid}` }}</span>
<span class="pc-alias" v-if="install.machine?.alias">{{ install.machine.alias }}</span> <span class="pc-alias" v-if="install.computer?.assetnumber">{{ install.computer.assetnumber }}</span>
</div> </div>
<div class="pc-version" v-if="install.version"> <div class="pc-version" v-if="install.version">
v{{ install.version }} v{{ install.version }}

View File

@@ -65,6 +65,10 @@
<input type="checkbox" v-model="form.isprinter" /> <input type="checkbox" v-model="form.isprinter" />
Printer App Printer App
</label> </label>
<label>
<input type="checkbox" v-model="form.isrequired" />
Required on all PCs
</label>
<label> <label>
<input type="checkbox" v-model="form.ishidden" /> <input type="checkbox" v-model="form.ishidden" />
Hidden Hidden
@@ -168,6 +172,7 @@ const form = ref({
isinstallable: false, isinstallable: false,
islicenced: false, islicenced: false,
isprinter: false, isprinter: false,
isrequired: false,
ishidden: false, ishidden: false,
applicationlink: '', applicationlink: '',
documentationpath: '', documentationpath: '',
@@ -196,6 +201,7 @@ onMounted(async () => {
isinstallable: app.isinstallable || false, isinstallable: app.isinstallable || false,
islicenced: app.islicenced || false, islicenced: app.islicenced || false,
isprinter: app.isprinter || false, isprinter: app.isprinter || false,
isrequired: app.isrequired || false,
ishidden: app.ishidden || false, ishidden: app.ishidden || false,
applicationlink: app.applicationlink || '', applicationlink: app.applicationlink || '',
documentationpath: app.documentationpath || '', documentationpath: app.documentationpath || '',
@@ -224,6 +230,7 @@ async function saveApplication() {
isinstallable: form.value.isinstallable, isinstallable: form.value.isinstallable,
islicenced: form.value.islicenced, islicenced: form.value.islicenced,
isprinter: form.value.isprinter, isprinter: form.value.isprinter,
isrequired: form.value.isrequired,
ishidden: form.value.ishidden, ishidden: form.value.ishidden,
applicationlink: form.value.applicationlink || null, applicationlink: form.value.applicationlink || null,
documentationpath: form.value.documentationpath || null, documentationpath: form.value.documentationpath || null,

View File

@@ -68,6 +68,14 @@
<span class="info-label">Name</span> <span class="info-label">Name</span>
<span class="info-value">{{ equipment.name }}</span> <span class="info-value">{{ equipment.name }}</span>
</div> </div>
<div class="info-row" v-if="isEnabled('gaugelabreference', 'equipment') && equipment.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ equipment.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'equipment') && equipment.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ equipment.maintenancereference }}</span>
</div>
<div class="info-row" v-if="equipment.serialnumber"> <div class="info-row" v-if="equipment.serialnumber">
<span class="info-label">Serial Number</span> <span class="info-label">Serial Number</span>
<span class="info-value mono">{{ equipment.serialnumber }}</span> <span class="info-value mono">{{ equipment.serialnumber }}</span>
@@ -229,8 +237,10 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { equipmentApi, assetsApi } from '../../api' import { equipmentApi, assetsApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue' import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute() const route = useRoute()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true) const loading = ref(true)
const equipment = ref(null) const equipment = ref(null)

View File

@@ -30,6 +30,31 @@
type="text" type="text"
class="form-control" class="form-control"
/> />
<small class="form-help">Layperson-friendly label</small>
</div>
</div>
<div class="form-row" v-if="isEnabled('gaugelabreference', 'equipment') || isEnabled('maintenancereference', 'equipment')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'equipment')">
<label for="gaugelabreference">Gauge Lab Reference</label>
<input
id="gaugelabreference"
v-model="form.gaugelabreference"
type="text"
class="form-control"
/>
<small class="form-help">Authoritative gauge lab asset reference (if tracked)</small>
</div>
<div class="form-group" v-if="isEnabled('maintenancereference', 'equipment')">
<label for="maintenancereference">Maintenance Reference</label>
<input
id="maintenancereference"
v-model="form.maintenancereference"
type="text"
class="form-control"
/>
<small class="form-help">Maintenance system asset reference (if tracked)</small>
</div> </div>
</div> </div>
@@ -331,6 +356,9 @@ import { equipmentApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, co
import ShopFloorMap from '../../components/ShopFloorMap.vue' import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue' import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme' import { currentTheme } from '../../stores/theme'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const { isEnabled } = useIdentifierFlags()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -346,6 +374,8 @@ const tempMapPosition = ref(null)
const form = ref({ const form = ref({
assetnumber: '', assetnumber: '',
name: '', name: '',
gaugelabreference: '',
maintenancereference: '',
serialnumber: '', serialnumber: '',
statusid: 1, statusid: 1,
equipmenttypeid: '', equipmenttypeid: '',
@@ -410,12 +440,12 @@ watch(() => form.value.controllervendorid, (newVal, oldVal) => {
onMounted(async () => { onMounted(async () => {
try { try {
// Load reference data in parallel // Load reference data in parallel
const [typesRes, statusRes, vendorRes, locRes, modelsRes, buRes, pcsRes, relTypesRes] = await Promise.all([ const [typesRes, statusRes, vendorRes, locRes, allModels, buRes, pcsRes, relTypesRes] = await Promise.all([
equipmentApi.types.list(), equipmentApi.types.list(),
assetsApi.statuses.list(), assetsApi.statuses.list(),
vendorsApi.list({ perpage: 500 }), vendorsApi.list({ perpage: 500 }),
locationsApi.list({ perpage: 500 }), locationsApi.list({ perpage: 500 }),
modelsApi.list({ perpage: 1000 }), modelsApi.listAll(), // backend caps perpage at 100; page through all
businessunitsApi.list({ perpage: 500 }), businessunitsApi.list({ perpage: 500 }),
computersApi.list({ perpage: 500 }), computersApi.list({ perpage: 500 }),
assetsApi.types.list() // Used for relationship types, will fix below assetsApi.types.list() // Used for relationship types, will fix below
@@ -425,7 +455,7 @@ onMounted(async () => {
statuses.value = statusRes.data.data || [] statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || [] vendors.value = vendorRes.data.data || []
locations.value = locRes.data.data || [] locations.value = locRes.data.data || []
models.value = modelsRes.data.data || [] models.value = allModels
businessunits.value = buRes.data.data || [] businessunits.value = buRes.data.data || []
pcs.value = pcsRes.data.data || [] pcs.value = pcsRes.data.data || []
@@ -456,6 +486,8 @@ onMounted(async () => {
form.value = { form.value = {
assetnumber: data.assetnumber || '', assetnumber: data.assetnumber || '',
name: data.name || '', name: data.name || '',
gaugelabreference: data.gaugelabreference || '',
maintenancereference: data.maintenancereference || '',
serialnumber: data.serialnumber || '', serialnumber: data.serialnumber || '',
statusid: data.statusid || 1, statusid: data.statusid || 1,
equipmenttypeid: data.equipment?.equipmenttypeid || '', equipmenttypeid: data.equipment?.equipmenttypeid || '',
@@ -526,6 +558,8 @@ async function saveEquipment() {
const data = { const data = {
assetnumber: form.value.assetnumber, assetnumber: form.value.assetnumber,
name: form.value.name || null, name: form.value.name || null,
gaugelabreference: form.value.gaugelabreference || null,
maintenancereference: form.value.maintenancereference || null,
serialnumber: form.value.serialnumber || null, serialnumber: form.value.serialnumber || null,
statusid: form.value.statusid || 1, statusid: form.value.statusid || 1,
equipmenttypeid: form.value.equipmenttypeid || null, equipmenttypeid: form.value.equipmenttypeid || null,

View File

@@ -24,7 +24,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Asset #</th> <th>Machine #</th>
<th>Name</th> <th>Name</th>
<th>Serial Number</th> <th>Serial Number</th>
<th>Type</th> <th>Type</th>

View File

@@ -111,6 +111,14 @@
<span class="info-label">Serial Number</span> <span class="info-label">Serial Number</span>
<span class="info-value mono">{{ device.serialnumber || '-' }}</span> <span class="info-value mono">{{ device.serialnumber || '-' }}</span>
</div> </div>
<div class="info-row" v-if="isEnabled('gaugelabreference', 'network_device') && device.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ device.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'network_device') && device.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ device.maintenancereference }}</span>
</div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Vendor</span> <span class="info-label">Vendor</span>
<span class="info-value">{{ device.networkdevice?.vendorname || '-' }}</span> <span class="info-value">{{ device.networkdevice?.vendorname || '-' }}</span>
@@ -181,6 +189,9 @@ import { Network, Router, Shield, Wifi, Camera, Server, Server as Rack, Globe }
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { networkApi } from '../../api' import { networkApi } from '../../api'
import AssetRelationships from '../../components/AssetRelationships.vue' import AssetRelationships from '../../components/AssetRelationships.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const { isEnabled } = useIdentifierFlags()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()

View File

@@ -54,6 +54,27 @@
</div> </div>
</div> </div>
<div class="form-row" v-if="isEnabled('gaugelabreference', 'network_device') || isEnabled('maintenancereference', 'network_device')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'network_device')">
<label for="gaugelabreference">Gauge Lab Reference</label>
<input
id="gaugelabreference"
v-model="form.gaugelabreference"
type="text"
class="form-control"
/>
</div>
<div class="form-group" v-if="isEnabled('maintenancereference', 'network_device')">
<label for="maintenancereference">Maintenance Reference</label>
<input
id="maintenancereference"
v-model="form.maintenancereference"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="locationid">Location</label> <label for="locationid">Location</label>
@@ -226,9 +247,12 @@ import {
networkApi, networkApi,
vendorsApi, vendorsApi,
locationsApi, locationsApi,
statusesApi, assetsApi,
businessunitsApi businessunitsApi
} from '../../api' } from '../../api'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const { isEnabled } = useIdentifierFlags()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -240,6 +264,8 @@ const form = ref({
assetnumber: '', assetnumber: '',
name: '', name: '',
serialnumber: '', serialnumber: '',
gaugelabreference: '',
maintenancereference: '',
statusid: '', statusid: '',
locationid: '', locationid: '',
businessunitid: '', businessunitid: '',
@@ -307,7 +333,7 @@ async function loadLocations() {
async function loadStatuses() { async function loadStatuses() {
try { try {
const response = await statusesApi.list({ perpage: 100 }) const response = await assetsApi.statuses.list()
statuses.value = response.data.data || [] statuses.value = response.data.data || []
} catch (err) { } catch (err) {
console.error('Error loading statuses:', err) console.error('Error loading statuses:', err)
@@ -332,6 +358,8 @@ async function loadDevice() {
form.value.assetnumber = data.assetnumber || '' form.value.assetnumber = data.assetnumber || ''
form.value.name = data.name || '' form.value.name = data.name || ''
form.value.serialnumber = data.serialnumber || '' form.value.serialnumber = data.serialnumber || ''
form.value.gaugelabreference = data.gaugelabreference || ''
form.value.maintenancereference = data.maintenancereference || ''
form.value.statusid = data.statusid || '' form.value.statusid = data.statusid || ''
form.value.locationid = data.locationid || '' form.value.locationid = data.locationid || ''
form.value.businessunitid = data.businessunitid || '' form.value.businessunitid = data.businessunitid || ''
@@ -365,6 +393,8 @@ async function submitForm() {
assetnumber: form.value.assetnumber, assetnumber: form.value.assetnumber,
name: form.value.name || null, name: form.value.name || null,
serialnumber: form.value.serialnumber || null, serialnumber: form.value.serialnumber || null,
gaugelabreference: form.value.gaugelabreference || null,
maintenancereference: form.value.maintenancereference || null,
statusid: form.value.statusid || null, statusid: form.value.statusid || null,
locationid: form.value.locationid || null, locationid: form.value.locationid || null,
businessunitid: form.value.businessunitid || null, businessunitid: form.value.businessunitid || null,

View File

@@ -54,7 +54,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Asset #</th> <th>Asset Tag</th>
<th>Hostname</th> <th>Hostname</th>
<th>Serial Number</th> <th>Serial Number</th>
<th>Type</th> <th>Type</th>

View File

@@ -16,7 +16,7 @@
<div class="hero-content"> <div class="hero-content">
<div class="hero-title"> <div class="hero-title">
<h1>{{ computer.assetnumber }}</h1> <h1>{{ computer.assetnumber }}</h1>
<span v-if="computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span> <span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
</div> </div>
<div class="hero-meta"> <div class="hero-meta">
<span class="badge badge-lg badge-info">Computer</span> <span class="badge badge-lg badge-info">Computer</span>
@@ -57,7 +57,7 @@
<span class="info-label">Name</span> <span class="info-label">Name</span>
<span class="info-value">{{ computer.name }}</span> <span class="info-value">{{ computer.name }}</span>
</div> </div>
<div class="info-row" v-if="computer.computer?.hostname"> <div class="info-row" v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname">
<span class="info-label">Hostname</span> <span class="info-label">Hostname</span>
<span class="info-value mono">{{ computer.computer.hostname }}</span> <span class="info-value mono">{{ computer.computer.hostname }}</span>
</div> </div>
@@ -65,6 +65,14 @@
<span class="info-label">Serial Number</span> <span class="info-label">Serial Number</span>
<span class="info-value mono">{{ computer.serialnumber }}</span> <span class="info-value mono">{{ computer.serialnumber }}</span>
</div> </div>
<div class="info-row" v-if="isEnabled('gaugelabreference', 'computer') && computer.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ computer.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'computer') && computer.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ computer.maintenancereference }}</span>
</div>
</div> </div>
</div> </div>
@@ -207,8 +215,10 @@ import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { computersApi, applicationsApi, assetsApi } from '../../api' import { computersApi, applicationsApi, assetsApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue' import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute() const route = useRoute()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true) const loading = ref(true)
const computer = ref(null) const computer = ref(null)

View File

@@ -17,7 +17,9 @@
type="text" type="text"
class="form-control" class="form-control"
required required
@input="onPcNumberInput"
/> />
<small class="form-hint">Defaults to the serial number; editable</small>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -32,7 +34,7 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group" v-if="isEnabled('fqdn', 'computer')">
<label for="hostname">Hostname</label> <label for="hostname">Hostname</label>
<input <input
id="hostname" id="hostname"
@@ -53,6 +55,28 @@
</div> </div>
</div> </div>
<div class="form-row" v-if="isEnabled('gaugelabreference', 'computer') || isEnabled('maintenancereference', 'computer')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'computer')">
<label for="gaugelabreference">Gauge Lab Reference</label>
<input
id="gaugelabreference"
v-model="form.gaugelabreference"
type="text"
class="form-control"
/>
</div>
<div class="form-group" v-if="isEnabled('maintenancereference', 'computer')">
<label for="maintenancereference">Maintenance Reference</label>
<input
id="maintenancereference"
v-model="form.maintenancereference"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="machinetypeid">PC Type *</label> <label for="machinetypeid">PC Type *</label>
@@ -66,10 +90,10 @@
<option value="">Select type...</option> <option value="">Select type...</option>
<option <option
v-for="pt in pcTypes" v-for="pt in pcTypes"
:key="pt.machinetypeid" :key="pt.computertypeid"
:value="pt.machinetypeid" :value="pt.computertypeid"
> >
{{ pt.machinetype }} {{ pt.computertype }}
</option> </option>
</select> </select>
</div> </div>
@@ -266,18 +290,28 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api' import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue' import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue' import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme' import { currentTheme } from '../../stores/theme'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const { isEnabled } = useIdentifierFlags()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const isEdit = computed(() => !!route.params.id) const isEdit = computed(() => !!route.params.id)
// PC Number (assetnumber) defaults to the serial number while the user hasn't
// typed their own. Editable; only auto-fills on a new PC.
const manualPcNumber = ref(false)
function onPcNumberInput() {
manualPcNumber.value = true
}
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const error = ref('') const error = ref('')
@@ -289,6 +323,8 @@ const form = ref({
alias: '', alias: '',
hostname: '', hostname: '',
serialnumber: '', serialnumber: '',
gaugelabreference: '',
maintenancereference: '',
machinetypeid: '', machinetypeid: '',
statusid: '', statusid: '',
vendorid: '', vendorid: '',
@@ -311,60 +347,65 @@ const models = ref([])
const locations = ref([]) const locations = ref([])
const operatingsystems = ref([]) const operatingsystems = ref([])
// Default PC Number to serial while the user hasn't typed their own (new PC only)
watch(() => form.value.serialnumber, (serial) => {
if (!isEdit.value && !manualPcNumber.value && serial) {
form.value.machinenumber = serial
}
})
// Filter models by selected vendor and PC type // Filter models by selected vendor and PC type
const filteredModels = computed(() => { const filteredModels = computed(() => {
return models.value.filter(m => { // filter by vendor only (PC type now maps to computertypeid, a different id
if (form.value.vendorid && m.vendorid !== form.value.vendorid) { // space than a model's machinetypeid)
return false if (!form.value.vendorid) return models.value
} return models.value.filter(m => m.vendorid === form.value.vendorid)
if (form.value.machinetypeid && m.machinetypeid !== form.value.machinetypeid) {
return false
}
return true
})
}) })
onMounted(async () => { onMounted(async () => {
try { try {
// Load reference data // Load reference data
const [ptRes, statusRes, vendorRes, modelsRes, locRes, osRes] = await Promise.all([ // perpage 100 so dropdowns aren't truncated to the default 20-row page
machinetypesApi.list({ category: 'PC' }), const [ptRes, statusRes, vendorRes, allModels, locRes, osRes] = await Promise.all([
statusesApi.list(), computersApi.types.list({ perpage: 100 }),
vendorsApi.list(), assetsApi.statuses.list(),
modelsApi.list(), vendorsApi.list({ perpage: 100 }),
locationsApi.list(), modelsApi.listAll(), // backend caps perpage at 100; page through all
operatingsystemsApi.list() locationsApi.list({ perpage: 100 }),
operatingsystemsApi.list({ perpage: 100 })
]) ])
pcTypes.value = ptRes.data.data || [] pcTypes.value = ptRes.data.data || []
statuses.value = statusRes.data.data || [] statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || [] vendors.value = vendorRes.data.data || []
models.value = modelsRes.data.data || [] models.value = allModels
locations.value = locRes.data.data || [] locations.value = locRes.data.data || []
operatingsystems.value = osRes.data.data || [] operatingsystems.value = osRes.data.data || []
// Load PC if editing // Load PC if editing (asset-based shape: extension under pc.computer)
if (isEdit.value) { if (isEdit.value) {
const response = await machinesApi.get(route.params.id) const response = await computersApi.get(route.params.id)
const pc = response.data.data const pc = response.data.data
const ext = pc.computer || {}
// Get IP from communications
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0] const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
form.value = { form.value = {
machinenumber: pc.machinenumber || '', machinenumber: pc.assetnumber || '',
alias: pc.alias || '', alias: pc.name && pc.name.toUpperCase() !== 'NONE' ? pc.name : '',
hostname: pc.hostname || '', hostname: ext.hostname || '',
serialnumber: pc.serialnumber || '', serialnumber: pc.serialnumber || '',
machinetypeid: pc.machinetype?.machinetypeid || '', gaugelabreference: pc.gaugelabreference || '',
statusid: pc.status?.statusid || '', maintenancereference: pc.maintenancereference || '',
vendorid: pc.vendor?.vendorid || '', machinetypeid: ext.computertypeid || '',
modelnumberid: pc.model?.modelnumberid || '', statusid: pc.statusid || '',
locationid: pc.location?.locationid || '', vendorid: ext.vendorid || '',
osid: pc.operatingsystem?.osid || '', modelnumberid: ext.modelnumberid || '',
loggedinuser: pc.loggedinuser || '', locationid: pc.locationid || '',
isvnc: pc.isvnc || false, osid: ext.osid || '',
iswinrm: pc.iswinrm || false, loggedinuser: ext.loggedinuser || '',
isvnc: ext.isvnc || false,
iswinrm: ext.iswinrm || false,
notes: pc.notes || '', notes: pc.notes || '',
mapx: pc.mapx ?? null, mapx: pc.mapx ?? null,
mapy: pc.mapy ?? null, mapy: pc.mapy ?? null,
@@ -402,40 +443,37 @@ async function savePC() {
saving.value = true saving.value = true
try { try {
const machineData = { // One payload for the computers plugin (asset core + computer extension +
machinenumber: form.value.machinenumber, // primary IP). "PC Number" is the business identifier (assetnumber).
alias: form.value.alias, const payload = {
hostname: form.value.hostname, assetnumber: form.value.machinenumber,
serialnumber: form.value.serialnumber, hostname: form.value.hostname || null,
machinetypeid: form.value.machinetypeid || null, serialnumber: form.value.serialnumber || null,
gaugelabreference: form.value.gaugelabreference || null,
maintenancereference: form.value.maintenancereference || null,
computertypeid: form.value.machinetypeid || null,
statusid: form.value.statusid || null, statusid: form.value.statusid || null,
vendorid: form.value.vendorid || null, vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null, modelnumberid: form.value.modelnumberid || null,
locationid: form.value.locationid || null, locationid: form.value.locationid || null,
osid: form.value.osid || null, osid: form.value.osid || null,
loggedinuser: form.value.loggedinuser, loggedinuser: form.value.loggedinuser || null,
isvnc: form.value.isvnc, isvnc: form.value.isvnc,
iswinrm: form.value.iswinrm, iswinrm: form.value.iswinrm,
notes: form.value.notes, notes: form.value.notes || null,
ipaddress: form.value.ipaddress || null,
mapx: form.value.mapx, mapx: form.value.mapx,
mapy: form.value.mapy mapy: form.value.mapy
} }
// only set display name when an alias is given, so we don't clobber it
let machineId if (form.value.alias) {
if (isEdit.value) { payload.name = form.value.alias
await machinesApi.update(route.params.id, machineData)
machineId = route.params.id
} else {
const response = await machinesApi.create(machineData)
machineId = response.data.data.machineid
} }
// Handle IP address - update communication record if (isEdit.value) {
if (form.value.ipaddress) { await computersApi.update(route.params.id, payload)
await machinesApi.updateCommunication(machineId, { } else {
ipaddress: form.value.ipaddress, await computersApi.create(payload)
isprimary: true
})
} }
router.push('/pcs') router.push('/pcs')

View File

@@ -24,7 +24,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Asset #</th> <th>Asset Tag</th>
<th>Hostname</th> <th>Hostname</th>
<th>Serial Number</th> <th>Serial Number</th>
<th>Type</th> <th>Type</th>
@@ -153,13 +153,15 @@ function getStatusClass(status) {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
} }
/* keep the cell as a table-cell; flex on a <td> strips table-cell layout and
offsets the row. lay tags out inline instead. */
.features { .features {
display: flex; white-space: nowrap;
gap: 0.375rem;
} }
.feature-tag { .feature-tag {
display: inline-block; display: inline-block;
margin-right: 0.375rem;
padding: 0.3rem 0.625rem; padding: 0.3rem 0.625rem;
font-size: 0.875rem; font-size: 0.875rem;
border-radius: 5px; border-radius: 5px;
@@ -167,6 +169,17 @@ function getStatusClass(status) {
color: var(--text-light); color: var(--text-light);
} }
.feature-tag:last-child {
margin-right: 0;
}
/* the global .actions rule is inline-flex, which also breaks table-cell
alignment when applied straight on a <td>; pin it back to a cell here. */
td.actions {
display: table-cell;
vertical-align: middle;
}
.feature-tag.active { .feature-tag.active {
background: #e3f2fd; background: #e3f2fd;
color: #1976d2; color: #1976d2;

View File

@@ -45,7 +45,7 @@
<template v-if="page[pos - 1]"> <template v-if="page[pos - 1]">
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div> <div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
<div class="qr-container"> <div class="qr-container">
<canvas :ref="el => setQrRef(el, pageIdx, pos)"></canvas> <img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
</div> </div>
<div class="info-section"> <div class="info-section">
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div> <div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
@@ -65,12 +65,14 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { printersApi } from '../../api' import { printersApi } from '../../api'
import QRCode from 'qrcode' import { renderQrDataUrl } from './qrLogo'
const printers = ref([]) const printers = ref([])
const selectedPrinters = ref([]) const selectedPrinters = ref([])
const loadingPrinters = ref(true) const loadingPrinters = ref(true)
const qrRefs = ref({}) // QR codes rendered to data-URL images (not live <canvas>): canvases are
// unreliable in print output, images print every time.
const qrImages = ref({})
const pageCount = computed(() => Math.ceil(selectedPrinters.value.length / 6) || 0) const pageCount = computed(() => Math.ceil(selectedPrinters.value.length / 6) || 0)
@@ -102,52 +104,19 @@ watch(selectedPrinters, async () => {
generateQRCodes() generateQRCodes()
}, { deep: true }) }, { deep: true })
function setQrRef(el, pageIdx, pos) { async function generateQRCodes() {
if (el) { const next = {}
qrRefs.value[`${pageIdx}-${pos}`] = el for (let pageIdx = 0; pageIdx < pages.value.length; pageIdx++) {
} const page = pages.value[pageIdx]
} for (let idx = 0; idx < page.length; idx++) {
const printer = page[idx]
function generateQRCodes() { if (!printer) continue
pages.value.forEach((page, pageIdx) => {
page.forEach((printer, idx) => {
if (!printer) return
const pos = idx + 1 const pos = idx + 1
const canvas = qrRefs.value[`${pageIdx}-${pos}`]
if (!canvas) return
const qrUrl = `${window.location.origin}/printers/${printer.printer?.printerid || printer.assetid}` const qrUrl = `${window.location.origin}/printers/${printer.printer?.printerid || printer.assetid}`
QRCode.toCanvas(canvas, qrUrl, { next[`${pageIdx}-${pos}`] = await renderQrDataUrl(qrUrl)
width: 144, }
margin: 0,
errorCorrectionLevel: 'H'
}).then(() => {
drawLogoOverlay(canvas)
}).catch(err => console.error('QR error:', err))
})
})
}
function drawLogoOverlay(canvas) {
const canvasContext = canvas.getContext('2d')
const size = canvas.width
const logoSize = Math.round(size * 0.22)
const x = (size - logoSize) / 2
const y = (size - logoSize) / 2
// White circle background
canvasContext.beginPath()
canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2)
canvasContext.fillStyle = '#fff'
canvasContext.fill()
// Load and draw the GE monogram
const img = new Image()
img.onload = () => {
canvasContext.drawImage(img, 0, 0, 32.5, 32, x, y, logoSize, logoSize)
} }
const svgStr = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>` qrImages.value = next
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr)
} }
function displayName(printer) { function displayName(printer) {
@@ -193,26 +162,27 @@ function print() {
@page { size: letter; margin: 0; } @page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; padding: 20px; } .no-print { margin-bottom: 20px; padding: 20px; }
.controls { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; } .controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
.controls h3 { margin-top: 0; } .controls h3 { margin-top: 0; }
.print-btn { .print-btn {
padding: 10px 30px; padding: 10px 30px;
font-size: 16px; font-size: 16px;
cursor: pointer; cursor: pointer;
background: #667eea; background: var(--primary);
color: white; color: white;
border: none; border: none;
border-radius: 5px; border-radius: 5px;
margin-right: 10px; margin-right: 10px;
} }
.print-btn:disabled { background: #ccc; cursor: not-allowed; } .print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.clear-btn { .clear-btn {
padding: 10px 20px; padding: 10px 20px;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
background: #dc3545; background: var(--danger);
color: white; color: white;
border: none; border: none;
border-radius: 5px; border-radius: 5px;
@@ -223,7 +193,7 @@ function print() {
padding: 10px 20px; padding: 10px 20px;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
background: #28a745; background: var(--success);
color: white; color: white;
border: none; border: none;
border-radius: 5px; border-radius: 5px;
@@ -235,31 +205,32 @@ function print() {
gap: 10px; gap: 10px;
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
border: 1px solid #ddd; border: 1px solid var(--border);
padding: 10px; padding: 10px;
background: #fafafa; background: var(--bg);
} }
.printer-item { .printer-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 8px; padding: 8px;
background: white; background: var(--bg-card);
border: 1px solid #ddd; color: var(--text);
border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
} }
.printer-item:hover { background: #f0f0f0; } .printer-item:hover { border-color: var(--primary); }
.printer-item.selected { background: #e7f1ff; border-color: #667eea; } .printer-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.printer-item input { margin-right: 10px; } .printer-item input { margin-right: 10px; }
.printer-item label { cursor: pointer; flex: 1; } .printer-item label { cursor: pointer; flex: 1; }
.printer-item .model { font-size: 11px; color: #666; } .printer-item .model { font-size: 11px; color: var(--text-light); }
.selected-count { font-weight: bold; margin: 10px 0; } .selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
.selected-count .count { color: #667eea; } .selected-count .count { color: var(--primary); }
.selected-count .pages { color: #28a745; } .selected-count .pages { color: var(--success); }
.loading-msg { text-align: center; padding: 2rem; color: #666; } .loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
.sheets-container { display: flex; flex-direction: column; gap: 20px; } .sheets-container { display: flex; flex-direction: column; gap: 20px; }
@@ -288,9 +259,11 @@ function print() {
box-sizing: border-box; box-sizing: border-box;
border: 1px dashed #ccc; border: 1px dashed #ccc;
} }
.label.filled { border: 2px solid #667eea; } .label.filled { border: 2px solid var(--primary); }
.label.empty { background: #fafafa; } .label.empty { background: #fafafa; }
.qr-img { width: 144px; height: 144px; display: block; }
.pos-1 { top: 0.875in; left: 1.1875in; } .pos-1 { top: 0.875in; left: 1.1875in; }
.pos-2 { top: 0.875in; left: 4.3125in; } .pos-2 { top: 0.875in; left: 4.3125in; }
.pos-3 { top: 4in; left: 1.1875in; } .pos-3 { top: 4in; left: 1.1875in; }
@@ -307,6 +280,13 @@ function print() {
.empty-label { color: #999; font-size: 14px; } .empty-label { color: #999; font-size: 14px; }
@media print { @media print {
/* Force the browser to print rendered images/colors even when the user's
"Background graphics" option is off. Without this the QR images and
borders can drop out of the printout. */
body, .print-sheet, .label, .qr-img, .qr-container {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; } body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; } .no-print { display: none !important; }
.sheets-container { gap: 0; } .sheets-container { gap: 0; }

View File

@@ -26,7 +26,7 @@
<template v-if="pos === parseInt(position)"> <template v-if="pos === parseInt(position)">
<div class="model-name">{{ printer.printer?.modelname || '' }}</div> <div class="model-name">{{ printer.printer?.modelname || '' }}</div>
<div class="qr-container"> <div class="qr-container">
<canvas ref="qrCanvas"></canvas> <img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
</div> </div>
<div class="info-section"> <div class="info-section">
<div class="csf-name">{{ printer.assetnumber }}</div> <div class="csf-name">{{ printer.assetnumber }}</div>
@@ -47,13 +47,15 @@
import { ref, computed, onMounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { printersApi } from '../../api' import { printersApi } from '../../api'
import QRCode from 'qrcode' import { renderQrDataUrl } from './qrLogo'
const route = useRoute() const route = useRoute()
const loading = ref(true) const loading = ref(true)
const printer = ref(null) const printer = ref(null)
const position = ref('1') const position = ref('1')
const qrCanvas = ref(null) // Render QR to a data-URL image, not a live <canvas>: canvases are unreliable
// in print output, images print every time.
const qrImage = ref('')
const ipAddress = computed(() => { const ipAddress = computed(() => {
// Check direct ipaddress field first (from list API) // Check direct ipaddress field first (from list API)
@@ -82,42 +84,10 @@ watch(position, async () => {
generateQR() generateQR()
}) })
function generateQR() { async function generateQR() {
const canvas = Array.isArray(qrCanvas.value) ? qrCanvas.value[0] : qrCanvas.value if (!printer.value) return
if (!canvas || !printer.value) return
const qrUrl = `${window.location.origin}/printers/${printer.value.printer?.printerid || printer.value.assetid}` const qrUrl = `${window.location.origin}/printers/${printer.value.printer?.printerid || printer.value.assetid}`
QRCode.toCanvas(canvas, qrUrl, { qrImage.value = await renderQrDataUrl(qrUrl)
width: 144,
margin: 0,
errorCorrectionLevel: 'H'
}).then(() => {
drawLogoOverlay(canvas)
}).catch(err => console.error('QR error:', err))
}
function drawLogoOverlay(canvas) {
const canvasContext = canvas.getContext('2d')
const size = canvas.width
const logoSize = Math.round(size * 0.22)
const x = (size - logoSize) / 2
const y = (size - logoSize) / 2
// White circle background
canvasContext.beginPath()
canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2)
canvasContext.fillStyle = '#fff'
canvasContext.fill()
// Load and draw the GE monogram (the circular part of the SVG)
const img = new Image()
img.onload = () => {
// Draw only the GE monogram portion (left 32x32 of the 138x32 SVG)
canvasContext.drawImage(img, 0, 0, 32.5, 32, x, y, logoSize, logoSize)
}
// Use a data URI with black fill for the monogram
const svgStr = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>`
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr)
} }
function print() { function print() {
@@ -134,13 +104,14 @@ function print() {
padding: 10px 30px; padding: 10px 30px;
font-size: 16px; font-size: 16px;
cursor: pointer; cursor: pointer;
background: #667eea; background: var(--primary);
color: white; color: white;
border: none; border: none;
border-radius: 5px; border-radius: 5px;
margin: 5px; margin: 5px;
} }
.print-btn:disabled { background: #ccc; cursor: not-allowed; } .print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.position-select { padding: 8px; font-size: 14px; margin-left: 10px; } .position-select { padding: 8px; font-size: 14px; margin-left: 10px; }
@@ -148,7 +119,7 @@ function print() {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
font-size: 1.125rem; font-size: 1.125rem;
color: #666; color: var(--text-light);
} }
.print-sheet { .print-sheet {
@@ -172,7 +143,9 @@ function print() {
box-sizing: border-box; box-sizing: border-box;
} }
.label.inactive { border: 1px dashed #ccc; } .label.inactive { border: 1px dashed #ccc; }
.label.active { border: 2px solid #667eea; } .label.active { border: 2px solid var(--primary); }
.qr-img { width: 144px; height: 144px; display: block; }
.pos-1 { top: 0.875in; left: 1.1875in; } .pos-1 { top: 0.875in; left: 1.1875in; }
.pos-2 { top: 0.875in; left: 4.3125in; } .pos-2 { top: 0.875in; left: 4.3125in; }
@@ -189,6 +162,12 @@ function print() {
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; } .csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
@media print { @media print {
/* Force rendered images/colors to print even when "Background graphics" is
off, otherwise the QR image can drop out. */
body, .print-sheet, .label, .qr-img, .qr-container {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; } body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; } .no-print { display: none !important; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; } .print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }

View File

@@ -207,26 +207,27 @@ function print() {
@page { size: letter; margin: 0; } @page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; padding: 20px; } .no-print { margin-bottom: 20px; padding: 20px; }
.controls { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; } .controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
.controls h3 { margin-top: 0; } .controls h3 { margin-top: 0; }
.print-btn { .print-btn {
padding: 10px 30px; padding: 10px 30px;
font-size: 16px; font-size: 16px;
cursor: pointer; cursor: pointer;
background: #667eea; background: var(--primary);
color: white; color: white;
border: none; border: none;
border-radius: 5px; border-radius: 5px;
margin-right: 10px; margin-right: 10px;
} }
.print-btn:disabled { background: #ccc; cursor: not-allowed; } .print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.clear-btn { .clear-btn {
padding: 10px 20px; padding: 10px 20px;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
background: #dc3545; background: var(--danger);
color: white; color: white;
border: none; border: none;
border-radius: 5px; border-radius: 5px;
@@ -237,7 +238,7 @@ function print() {
padding: 10px 20px; padding: 10px 20px;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
background: #28a745; background: var(--success);
color: white; color: white;
border: none; border: none;
border-radius: 5px; border-radius: 5px;
@@ -249,31 +250,32 @@ function print() {
gap: 10px; gap: 10px;
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
border: 1px solid #ddd; border: 1px solid var(--border);
padding: 10px; padding: 10px;
background: #fafafa; background: var(--bg);
} }
.usb-item { .usb-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 8px; padding: 8px;
background: white; background: var(--bg-card);
border: 1px solid #ddd; color: var(--text);
border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
} }
.usb-item:hover { background: #f0f0f0; } .usb-item:hover { border-color: var(--primary); }
.usb-item.selected { background: #e7f1ff; border-color: #667eea; } .usb-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.usb-item input { margin-right: 10px; } .usb-item input { margin-right: 10px; }
.usb-item label { cursor: pointer; flex: 1; } .usb-item label { cursor: pointer; flex: 1; }
.usb-item .alias { font-size: 11px; color: #666; } .usb-item .alias { font-size: 11px; color: var(--text-light); }
.selected-count { font-weight: bold; margin: 10px 0; } .selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
.selected-count .count { color: #667eea; } .selected-count .count { color: var(--primary); }
.selected-count .pages { color: #28a745; } .selected-count .pages { color: var(--success); }
.loading-msg { text-align: center; padding: 2rem; color: #666; } .loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
.sheets-container { display: flex; flex-direction: column; gap: 20px; } .sheets-container { display: flex; flex-direction: column; gap: 20px; }
@@ -298,7 +300,7 @@ function print() {
border: 1px dashed #ccc; border: 1px dashed #ccc;
overflow: hidden; overflow: hidden;
} }
.label-cell.has-content { border: 1px solid #667eea; } .label-cell.has-content { border: 1px solid var(--primary); }
.label-cell.empty { background: #fafafa; } .label-cell.empty { background: #fafafa; }
.cell-1 { top: 0.875in; left: 1.1875in; } .cell-1 { top: 0.875in; left: 1.1875in; }
@@ -353,6 +355,12 @@ function print() {
} }
@media print { @media print {
/* Force the barcodes/borders to print even when "Background graphics" is
off. */
body, .print-sheet, .label-cell, .mini-label, .barcode-container {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; } body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; } .no-print { display: none !important; }
.sheets-container { gap: 0; } .sheets-container { gap: 0; }

View File

@@ -0,0 +1,51 @@
// Shared GE monogram + QR-with-logo rendering for the printer QR label pages.
// Both PrinterQRBatch and PrinterQRSingle render each QR to a data-URL image
// (canvases print unreliably) with the GE monogram composited in the center.
import QRCode from 'qrcode'
const GE_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>`
let logoImage = null
function loadLogo() {
if (logoImage) return Promise.resolve(logoImage)
return new Promise(resolve => {
const img = new Image()
img.onload = () => { logoImage = img; resolve(img) }
img.onerror = () => resolve(null)
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(GE_LOGO_SVG)
})
}
function drawLogoOverlay(canvas, logo) {
const canvasContext = canvas.getContext('2d')
const size = canvas.width
const logoSize = Math.round(size * 0.22)
const x = (size - logoSize) / 2
const y = (size - logoSize) / 2
// White circle background behind the monogram
canvasContext.beginPath()
canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2)
canvasContext.fillStyle = '#fff'
canvasContext.fill()
if (logo) {
canvasContext.drawImage(logo, 0, 0, 32.5, 32, x, y, logoSize, logoSize)
}
}
// Render a QR for `url` to a PNG data URL with the GE monogram composited in.
// Returns the data URL string, or '' on failure.
export async function renderQrDataUrl(url) {
const logo = await loadLogo()
const canvas = document.createElement('canvas')
try {
await QRCode.toCanvas(canvas, url, { width: 144, margin: 0, errorCorrectionLevel: 'H' })
drawLogoOverlay(canvas, logo)
return canvas.toDataURL('image/png')
} catch (err) {
console.error('QR error:', err)
return ''
}
}

View File

@@ -18,7 +18,7 @@
<div class="hero-card"> <div class="hero-card">
<div class="hero-content"> <div class="hero-content">
<div class="hero-title"> <div class="hero-title">
<h1>{{ printer.name || printer.assetnumber }}</h1> <h1>{{ displayTitle }}</h1>
</div> </div>
<div class="hero-meta"> <div class="hero-meta">
<span class="badge badge-lg badge-printer">Printer</span> <span class="badge badge-lg badge-printer">Printer</span>
@@ -63,7 +63,7 @@
<span class="info-label">Windows Name</span> <span class="info-label">Windows Name</span>
<span class="info-value mono">{{ printer.printer.windowsname }}</span> <span class="info-value mono">{{ printer.printer.windowsname }}</span>
</div> </div>
<div class="info-row" v-if="printer.printer?.hostname"> <div class="info-row" v-if="isEnabled('fqdn', 'printer') && printer.printer?.hostname">
<span class="info-label">Hostname / FQDN</span> <span class="info-label">Hostname / FQDN</span>
<span class="info-value mono">{{ printer.printer.hostname }}</span> <span class="info-value mono">{{ printer.printer.hostname }}</span>
</div> </div>
@@ -75,6 +75,14 @@
<span class="info-label">Serial Number</span> <span class="info-label">Serial Number</span>
<span class="info-value mono">{{ printer.serialnumber }}</span> <span class="info-value mono">{{ printer.serialnumber }}</span>
</div> </div>
<div class="info-row" v-if="isEnabled('gaugelabreference', 'printer') && printer.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ printer.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'printer') && printer.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ printer.maintenancereference }}</span>
</div>
</div> </div>
</div> </div>
@@ -172,23 +180,30 @@
</div> </div>
<div v-else class="supplies-grid"> <div v-else class="supplies-grid">
<div v-for="supply in supplies" :key="supply.supplyid" class="supply-item"> <div v-for="supply in supplies" :key="supply.itemid || supply.name" class="supply-item">
<div class="supply-header"> <div class="supply-header">
<span class="supply-name">{{ supply.supplyname }}</span> <span class="supply-name">{{ supply.name }}</span>
<span class="supply-level" :class="getSupplyLevelClass(supply.currentlevel)"> <span class="supply-level" :class="supply.status">
{{ supply.currentlevel !== null ? `${supply.currentlevel}%` : 'N/A' }} {{ supply.level !== null ? `${supply.level}%` : 'N/A' }}
</span> </span>
</div> </div>
<div class="supply-bar"> <div class="supply-bar">
<div <div
class="supply-bar-fill" class="supply-bar-fill"
:class="getSupplyLevelClass(supply.currentlevel)" :class="supply.status"
:style="{ width: `${supply.currentlevel || 0}%` }" :style="{ width: `${supply.level || 0}%` }"
></div> ></div>
</div> </div>
<div class="supply-meta"> <div class="supply-meta">
<span>{{ supply.supplytypename }}</span> <span>{{ formatSupplyType(supply.supplytype) }}<template v-if="supply.iswaste"> ({{ supply.remaining }}% remaining)</template></span>
<span v-if="supply.partnumber">Part: {{ supply.partnumber }}</span> <span v-if="supply.partnumbers && supply.partnumbers.length" class="supply-parts">
<span
v-for="part in supply.partnumbers"
:key="part.partnumber"
class="supply-part"
:data-tip="`Part ${part.partnumber}` + (part.pageyield ? ` - ${part.pageyield} pages` : '')"
>{{ part.marketingname || part.partnumber }}</span>
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -238,14 +253,26 @@ import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { printersApi } from '../../api' import { printersApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue' import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute() const route = useRoute()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true) const loading = ref(true)
const printer = ref(null) const printer = ref(null)
const supplies = ref([]) const supplies = ref([])
const drivers = ref([]) const drivers = ref([])
// Best display identifier for a printer. name is often the literal "NONE",
// so fall back to the Windows name, then hostname, then asset number.
const displayTitle = computed(() => {
const p = printer.value
if (!p) return ''
const name = (p.name || '').trim()
if (name && name.toUpperCase() !== 'NONE') return name
return p.printer?.windowsname || p.printer?.hostname || p.assetnumber
})
// Get IP address from communications // Get IP address from communications
const ipAddress = computed(() => { const ipAddress = computed(() => {
if (!printer.value?.communications) return null if (!printer.value?.communications) return null
@@ -262,7 +289,8 @@ onMounted(async () => {
]) ])
printer.value = printerRes.data.data printer.value = printerRes.data.data
supplies.value = suppliesRes.data.data || [] // supplies endpoint returns {ipaddress, pingstatus, supplies:[...]}
supplies.value = suppliesRes.data.data?.supplies || []
drivers.value = driversRes.data.data || [] drivers.value = driversRes.data.data || []
} catch (error) { } catch (error) {
console.error('Error loading printer:', error) console.error('Error loading printer:', error)
@@ -280,11 +308,9 @@ function getStatusClass(status) {
return 'badge-info' return 'badge-info'
} }
function getSupplyLevelClass(level) { function formatSupplyType(supplytype) {
if (level === null || level === undefined) return '' if (!supplytype) return ''
if (level <= 10) return 'critical' return supplytype.charAt(0).toUpperCase() + supplytype.slice(1)
if (level <= 25) return 'low'
return 'ok'
} }
function formatDate(dateStr) { function formatDate(dateStr) {
@@ -360,4 +386,33 @@ function formatDate(dateStr) {
color: var(--text-light); color: var(--text-light);
margin-top: 0.625rem; margin-top: 0.625rem;
} }
.supply-parts {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
}
.supply-part {
cursor: help;
border-bottom: 1px dotted var(--text-light);
position: relative;
}
/* instant CSS tooltip, no native title delay */
.supply-part:hover::after {
content: attr(data-tip);
position: absolute;
bottom: 125%;
right: 0;
white-space: nowrap;
background: var(--text);
color: var(--bg-card);
padding: 0.35rem 0.6rem;
border-radius: 4px;
font-size: 0.85rem;
z-index: 10;
pointer-events: none;
}
</style> </style>

View File

@@ -37,7 +37,7 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group" v-if="isEnabled('fqdn', 'printer')">
<label for="hostname">Hostname (FQDN)</label> <label for="hostname">Hostname (FQDN)</label>
<input <input
id="hostname" id="hostname"
@@ -61,6 +61,28 @@
</div> </div>
</div> </div>
<div class="form-row" v-if="isEnabled('gaugelabreference', 'printer') || isEnabled('maintenancereference', 'printer')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'printer')">
<label for="gaugelabreference">Gauge Lab Reference</label>
<input
id="gaugelabreference"
v-model="form.gaugelabreference"
type="text"
class="form-control"
/>
</div>
<div class="form-group" v-if="isEnabled('maintenancereference', 'printer')">
<label for="maintenancereference">Maintenance Reference</label>
<input
id="maintenancereference"
v-model="form.maintenancereference"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="machinetypeid">Printer Type *</label> <label for="machinetypeid">Printer Type *</label>
@@ -73,11 +95,11 @@
> >
<option value="">Select type...</option> <option value="">Select type...</option>
<option <option
v-for="mt in printerTypes" v-for="pt in printerTypes"
:key="mt.machinetypeid" :key="pt.printertypeid"
:value="mt.machinetypeid" :value="pt.printertypeid"
> >
{{ mt.machinetype }} {{ pt.printertype }}
</option> </option>
</select> </select>
</div> </div>
@@ -127,8 +149,9 @@
id="modelnumberid" id="modelnumberid"
v-model="form.modelnumberid" v-model="form.modelnumberid"
class="form-control" class="form-control"
:disabled="!form.vendorid"
> >
<option value="">Select model...</option> <option value="">{{ form.vendorid ? 'Select model...' : 'Select a vendor first' }}</option>
<option <option
v-for="m in filteredModels" v-for="m in filteredModels"
:key="m.modelnumberid" :key="m.modelnumberid"
@@ -137,8 +160,8 @@
{{ m.modelnumber }} {{ m.modelnumber }}
</option> </option>
</select> </select>
<small v-if="!form.vendorid && !form.machinetypeid" class="form-hint"> <small v-if="!form.vendorid" class="form-hint">
Select vendor or printer type to filter models Select a vendor first to choose a model
</small> </small>
</div> </div>
</div> </div>
@@ -266,10 +289,13 @@
<script setup> <script setup>
import { ref, onMounted, computed, watch } from 'vue' import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, printersApi, modelsApi } from '../../api' import { assetsApi, vendorsApi, locationsApi, printersApi, modelsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue' import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue' import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme' import { currentTheme } from '../../stores/theme'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const { isEnabled } = useIdentifierFlags()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -289,6 +315,8 @@ const form = ref({
alias: '', alias: '',
hostname: '', hostname: '',
serialnumber: '', serialnumber: '',
gaugelabreference: '',
maintenancereference: '',
machinetypeid: '', machinetypeid: '',
statusid: '', statusid: '',
vendorid: '', vendorid: '',
@@ -317,8 +345,10 @@ const filteredModels = computed(() => {
if (form.value.vendorid && m.vendorid !== form.value.vendorid) { if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
return false return false
} }
// Filter by printer type if selected // Filter by printer type if selected, but only exclude models that have a
if (form.value.machinetypeid && m.machinetypeid !== form.value.machinetypeid) { // type set and it differs. Most models have no machinetypeid, so excluding
// null-typed models would hide the printer's own model from the dropdown.
if (form.value.machinetypeid && m.machinetypeid && m.machinetypeid !== form.value.machinetypeid) {
return false return false
} }
return true return true
@@ -422,47 +452,55 @@ function onWindowsNameInput() {
onMounted(async () => { onMounted(async () => {
try { try {
// Load reference data // Load reference data (models paged in full via listAll, see api/index.js)
const [mtRes, statusRes, vendorRes, modelsRes, locRes] = await Promise.all([ // perpage 100 so dropdowns aren't truncated to the default 20-row page
machinetypesApi.list({ category: 'Printer' }), // (e.g. 44 vendors; the editing record's vendor can be past row 20)
statusesApi.list(), const [mtRes, statusRes, vendorRes, allModels, locRes] = await Promise.all([
vendorsApi.list(), printersApi.types.list({ perpage: 100 }),
modelsApi.list(), assetsApi.statuses.list(),
locationsApi.list() vendorsApi.list({ perpage: 100 }),
modelsApi.listAll(),
locationsApi.list({ perpage: 100 })
]) ])
printerTypes.value = mtRes.data.data || [] printerTypes.value = mtRes.data.data || []
statuses.value = statusRes.data.data || [] statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || [] vendors.value = vendorRes.data.data || []
models.value = modelsRes.data.data || [] models.value = allModels
locations.value = locRes.data.data || [] locations.value = locRes.data.data || []
// Load printer if editing // Load printer if editing
if (isEdit.value) { if (isEdit.value) {
const response = await printersApi.get(route.params.id) const response = await printersApi.get(route.params.id)
const printer = response.data.data const printer = response.data.data
// asset-based shape: printer extension fields live under printer.printer
const ext = printer.printer || {}
// Get IP from communications // Get IP from communications
const primaryComm = printer.communications?.find(c => c.isprimary) || printer.communications?.[0] const primaryComm = printer.communications?.find(c => c.isprimary) || printer.communications?.[0]
form.value = { form.value = {
machinenumber: printer.machinenumber || '', // "Windows Name" is the printer's business identifier (assetnumber).
alias: printer.alias || '', // Prefer the explicit windowsname, then fall back to assetnumber.
hostname: printer.hostname || '', machinenumber: ext.windowsname || printer.assetnumber || '',
alias: printer.name && printer.name.toUpperCase() !== 'NONE' ? printer.name : '',
hostname: ext.hostname || '',
serialnumber: printer.serialnumber || '', serialnumber: printer.serialnumber || '',
machinetypeid: printer.machinetype?.machinetypeid || '', gaugelabreference: printer.gaugelabreference || '',
statusid: printer.status?.statusid || '', maintenancereference: printer.maintenancereference || '',
vendorid: printer.vendor?.vendorid || '', machinetypeid: ext.printertypeid || '',
modelnumberid: printer.model?.modelnumberid || '', statusid: printer.statusid || '',
locationid: printer.location?.locationid || '', vendorid: ext.vendorid || '',
modelnumberid: ext.modelnumberid || '',
locationid: printer.locationid || '',
notes: printer.notes || '', notes: printer.notes || '',
mapx: printer.mapx ?? null, mapx: printer.mapx ?? null,
mapy: printer.mapy ?? null, mapy: printer.mapy ?? null,
// Printer-specific // Printer-specific
ipaddress: primaryComm?.ipaddress || '', ipaddress: primaryComm?.ipaddress || '',
csfname: printer.printerdata?.sharename || '', csfname: ext.sharename || '',
installpath: printer.printerdata?.installpath || '', installpath: ext.installpath || '',
pin: printer.printerdata?.pin || '' pin: ext.pin || ''
} }
// Don't auto-generate for existing printers // Don't auto-generate for existing printers
@@ -500,48 +538,38 @@ async function savePrinter() {
saving.value = true saving.value = true
try { try {
const machineData = { // One payload for the printers plugin, which owns asset core + extension +
machinenumber: form.value.machinenumber, // primary communication. The "Windows Name" field is the business
alias: form.value.alias, // identifier, written to both assetnumber and the extension windowsname.
hostname: form.value.hostname, const payload = {
serialnumber: form.value.serialnumber, assetnumber: form.value.machinenumber,
machinetypeid: form.value.machinetypeid || null, windowsname: form.value.machinenumber || null,
hostname: form.value.hostname || null,
serialnumber: form.value.serialnumber || null,
gaugelabreference: form.value.gaugelabreference || null,
maintenancereference: form.value.maintenancereference || null,
printertypeid: form.value.machinetypeid || null,
statusid: form.value.statusid || null, statusid: form.value.statusid || null,
vendorid: form.value.vendorid || null, vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null, modelnumberid: form.value.modelnumberid || null,
locationid: form.value.locationid || null, locationid: form.value.locationid || null,
notes: form.value.notes, sharename: form.value.csfname || null,
iscsf: !!form.value.csfname,
installpath: form.value.installpath || null,
pin: form.value.pin || null,
ipaddress: form.value.ipaddress || null,
mapx: form.value.mapx, mapx: form.value.mapx,
mapy: form.value.mapy mapy: form.value.mapy
} }
// only set the display name when an alias is given, so we don't clobber it
const printerData = { if (form.value.alias) {
windowsname: form.value.machinenumber, // Windows name is the machinenumber payload.name = form.value.alias
sharename: form.value.csfname,
installpath: form.value.installpath,
pin: form.value.pin,
iscsf: !!form.value.csfname // Auto-set based on whether CSF name is filled
} }
// Handle IP address - need to update/create communication record
const communicationData = form.value.ipaddress ? {
ipaddress: form.value.ipaddress,
isprimary: true
} : null
if (isEdit.value) { if (isEdit.value) {
await machinesApi.update(route.params.id, machineData) await printersApi.update(route.params.id, payload)
await printersApi.updateExtension(route.params.id, printerData)
if (communicationData) {
await printersApi.updateCommunication(route.params.id, communicationData)
}
} else { } else {
const response = await machinesApi.create(machineData) await printersApi.create(payload)
const newId = response.data.data.machineid
await printersApi.updateExtension(newId, printerData)
if (communicationData) {
await printersApi.updateCommunication(newId, communicationData)
}
} }
router.push('/printers') router.push('/printers')
@@ -592,12 +620,11 @@ async function savePrinter() {
color: var(--text-light, #666); color: var(--text-light, #666);
} }
/* auto-fill cue: a subtle translucent blue tint + accent border that reads
correctly over both light and dark backgrounds. Text color stays themed
(no solid light fill that turns into an unreadable white box in dark mode). */
.auto-generated { .auto-generated {
background-color: #f0f7ff; background-color: rgba(33, 150, 243, 0.12);
border-color: #90caf9; border-color: #90caf9;
} }
.auto-generated:focus {
background-color: white;
}
</style> </style>

View File

@@ -17,6 +17,12 @@
placeholder="Search printers..." placeholder="Search printers..."
@input="debouncedSearch" @input="debouncedSearch"
/> />
<select v-model="typeFilter" class="form-control" @change="onFilterChange">
<option value="">All types</option>
<option v-for="pt in printerTypes" :key="pt.printertypeid" :value="pt.printertypeid">
{{ pt.printertype }}
</option>
</select>
</div> </div>
<div class="card"> <div class="card">
@@ -27,9 +33,10 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Asset #</th> <th>Asset Tag</th>
<th>Name</th> <th>Name</th>
<th>Business Unit</th> <th>Business Unit</th>
<th>Type</th>
<th>Model</th> <th>Model</th>
<th>Status</th> <th>Status</th>
<th>Actions</th> <th>Actions</th>
@@ -38,8 +45,9 @@
<tbody> <tbody>
<tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid"> <tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid">
<td>{{ printer.assetnumber }}</td> <td>{{ printer.assetnumber }}</td>
<td>{{ printer.name || '-' }}</td> <td>{{ printer.name && printer.name.toUpperCase() !== 'NONE' ? printer.name : (printer.printer?.hostname || '-') }}</td>
<td>{{ printer.businessunitname || '-' }}</td> <td>{{ printer.businessunitname || '-' }}</td>
<td>{{ printer.printer?.printertypename || '-' }}</td>
<td>{{ printer.printer?.modelname || '-' }}</td> <td>{{ printer.printer?.modelname || '-' }}</td>
<td> <td>
<span class="badge" :class="getStatusClass(printer.statusname)"> <span class="badge" :class="getStatusClass(printer.statusname)">
@@ -56,7 +64,7 @@
</td> </td>
</tr> </tr>
<tr v-if="printers.length === 0"> <tr v-if="printers.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);"> <td colspan="7" style="text-align: center; color: var(--text-light);">
No printers found No printers found
</td> </td>
</tr> </tr>
@@ -83,6 +91,8 @@ import { printersApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue' import PaginationBar from '../../components/PaginationBar.vue'
const printers = ref([]) const printers = ref([])
const printerTypes = ref([])
const typeFilter = ref('')
const loading = ref(true) const loading = ref(true)
const search = ref('') const search = ref('')
const page = ref(1) const page = ref(1)
@@ -91,7 +101,13 @@ const perPage = ref(20)
let searchTimeout = null let searchTimeout = null
onMounted(() => { onMounted(async () => {
try {
const response = await printersApi.types.list({ perpage: 100 })
printerTypes.value = response.data.data || []
} catch (error) {
console.error('Error loading printer types:', error)
}
loadPrinters() loadPrinters()
}) })
@@ -103,6 +119,7 @@ async function loadPrinters() {
perpage: perPage.value perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
if (typeFilter.value) params.typeid = typeFilter.value
const response = await printersApi.list(params) const response = await printersApi.list(params)
printers.value = response.data.data || [] printers.value = response.data.data || []
@@ -122,6 +139,11 @@ function debouncedSearch() {
}, 300) }, 300)
} }
function onFilterChange() {
page.value = 1
loadPrinters()
}
function goToPage(p) { function goToPage(p) {
page.value = p page.value = p
loadPrinters() loadPrinters()

View File

@@ -23,7 +23,7 @@
<tr v-for="bu in items" :key="bu.businessunitid"> <tr v-for="bu in items" :key="bu.businessunitid">
<td>{{ bu.businessunit }}</td> <td>{{ bu.businessunit }}</td>
<td>{{ bu.code || '-' }}</td> <td>{{ bu.code || '-' }}</td>
<td>{{ bu.description || '-' }}</td> <td class="cell-truncate" :title="bu.description">{{ bu.description || '-' }}</td>
<td class="actions"> <td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(bu)">Edit</button> <button class="btn btn-secondary btn-sm" @click="openModal(bu)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(bu)">Delete</button> <button class="btn btn-danger btn-sm" @click="confirmDelete(bu)">Delete</button>

View File

@@ -25,6 +25,7 @@
<thead> <thead>
<tr> <tr>
<th>Location Name</th> <th>Location Name</th>
<th>Type</th>
<th>Building</th> <th>Building</th>
<th>Floor</th> <th>Floor</th>
<th>Room</th> <th>Room</th>
@@ -35,10 +36,11 @@
<tbody> <tbody>
<tr v-for="loc in locations" :key="loc.locationid"> <tr v-for="loc in locations" :key="loc.locationid">
<td>{{ loc.locationname }}</td> <td>{{ loc.locationname }}</td>
<td>{{ loc.locationtypename || '-' }}</td>
<td>{{ loc.building || '-' }}</td> <td>{{ loc.building || '-' }}</td>
<td>{{ loc.floor || '-' }}</td> <td>{{ loc.floor || '-' }}</td>
<td>{{ loc.room || '-' }}</td> <td>{{ loc.room || '-' }}</td>
<td>{{ loc.description || '-' }}</td> <td class="cell-truncate" :title="loc.description">{{ loc.description || '-' }}</td>
<td class="actions"> <td class="actions">
<button <button
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"
@@ -55,7 +57,7 @@
</td> </td>
</tr> </tr>
<tr v-if="locations.length === 0"> <tr v-if="locations.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);"> <td colspan="7" style="text-align: center; color: var(--text-light);">
No locations found No locations found
</td> </td>
</tr> </tr>
@@ -125,6 +127,32 @@
</div> </div>
</div> </div>
<div class="form-row">
<div class="form-group">
<label for="locationtypeid">Type</label>
<select id="locationtypeid" v-model="form.locationtypeid" class="form-control">
<option value="">Select type...</option>
<option v-for="t in locationTypes" :key="t.locationtypeid" :value="t.locationtypeid">
{{ t.locationtype }}
</option>
</select>
</div>
<div class="form-group">
<label for="parentlocationid">Parent Location</label>
<select id="parentlocationid" v-model="form.parentlocationid" class="form-control">
<option value="">None</option>
<option
v-for="l in parentOptions"
:key="l.locationid"
:value="l.locationid"
>
{{ l.locationname }}
</option>
</select>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="description">Description</label> <label for="description">Description</label>
<textarea <textarea
@@ -180,11 +208,13 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { locationsApi } from '../../api' import { locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue' import PaginationBar from '../../components/PaginationBar.vue'
const locations = ref([]) const locations = ref([])
const locationTypes = ref([])
const allLocations = ref([])
const loading = ref(true) const loading = ref(true)
const search = ref('') const search = ref('')
const page = ref(1) const page = ref(1)
@@ -205,12 +235,29 @@ const form = ref({
floor: '', floor: '',
room: '', room: '',
description: '', description: '',
locationtypeid: '',
parentlocationid: '',
mapimage: '' mapimage: ''
}) })
// parent options = all locations except the one being edited (no self-parent)
const parentOptions = computed(() =>
allLocations.value.filter(l => l.locationid !== editingLocation.value?.locationid)
)
let searchTimeout = null let searchTimeout = null
onMounted(() => { onMounted(async () => {
try {
const [typesRes, allRes] = await Promise.all([
locationsApi.types.list(),
locationsApi.list({ perpage: 100 })
])
locationTypes.value = typesRes.data.data || []
allLocations.value = allRes.data.data || []
} catch (err) {
console.error('Error loading location types:', err)
}
loadLocations() loadLocations()
}) })
@@ -261,6 +308,8 @@ function openModal(loc = null) {
floor: loc.floor || '', floor: loc.floor || '',
room: loc.room || '', room: loc.room || '',
description: loc.description || '', description: loc.description || '',
locationtypeid: loc.locationtypeid || '',
parentlocationid: loc.parentlocationid || '',
mapimage: loc.mapimage || '' mapimage: loc.mapimage || ''
} }
} else { } else {
@@ -270,6 +319,8 @@ function openModal(loc = null) {
floor: '', floor: '',
room: '', room: '',
description: '', description: '',
locationtypeid: '',
parentlocationid: '',
mapimage: '' mapimage: ''
} }
} }
@@ -287,10 +338,15 @@ async function saveLocation() {
saving.value = true saving.value = true
try { try {
const payload = {
...form.value,
locationtypeid: form.value.locationtypeid || null,
parentlocationid: form.value.parentlocationid || null
}
if (editingLocation.value) { if (editingLocation.value) {
await locationsApi.update(editingLocation.value.locationid, form.value) await locationsApi.update(editingLocation.value.locationid, payload)
} else { } else {
await locationsApi.create(form.value) await locationsApi.create(payload)
} }
closeModal() closeModal()
loadLocations() loadLocations()

View File

@@ -27,7 +27,7 @@
{{ mt.category }} {{ mt.category }}
</span> </span>
</td> </td>
<td>{{ mt.description || '-' }}</td> <td class="cell-truncate" :title="mt.description">{{ mt.description || '-' }}</td>
<td class="actions"> <td class="actions">
<button <button
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"

View File

@@ -0,0 +1,437 @@
<template>
<div>
<div class="page-header">
<h2>Model Toners and Supplies</h2>
<span class="subtitle">Map toner, drum, and waste part numbers to each printer model.</span>
</div>
<div class="supplies-layout">
<!-- model picker -->
<div class="card model-panel">
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search models..."
@input="debouncedSearch"
/>
<label class="withsupplies">
<input v-model="onlyWithSupplies" type="checkbox" @change="loadModels" />
With supplies only
</label>
</div>
<div v-if="loadingModels" class="loading">Loading...</div>
<div v-else class="model-list">
<button
v-for="m in models"
:key="m.modelnumberid"
class="model-row"
:class="{ active: selectedModel && selectedModel.modelnumberid === m.modelnumberid }"
@click="selectModel(m)"
>
<span class="model-name">{{ m.modelnumber }}</span>
<span class="model-meta">
<span class="vendor">{{ m.vendor || 'No vendor' }}</span>
<span class="badge" :class="m.supplycount ? 'badge-success' : ''">
{{ m.supplycount }}
</span>
</span>
</button>
<p v-if="!models.length" class="empty-state">No models match.</p>
</div>
</div>
<!-- supplies for the selected model -->
<div class="card supply-panel">
<div v-if="!selectedModel" class="empty-state">
Select a model to view and edit its supplies.
</div>
<template v-else>
<div class="panel-header">
<h3>{{ selectedModel.modelnumber }}</h3>
<button class="btn btn-primary" @click="openModal()">+ Add Supply</button>
</div>
<div v-if="loadingSupplies" class="loading">Loading...</div>
<div v-else class="table-container">
<table v-if="supplies.length">
<thead>
<tr>
<th>Type</th>
<th>Color</th>
<th>Tier</th>
<th>Part Number</th>
<th>Name</th>
<th>Yield</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="s in supplies" :key="s.modelsupplyid">
<td>{{ s.supplytype }}</td>
<td>
<span v-if="s.color !== 'none'" class="color-dot" :class="'dot-' + s.color"></span>
{{ s.color === 'none' ? '-' : s.color }}
</td>
<td>{{ s.capacitytier }}</td>
<td class="partnumber">{{ s.partnumber }}</td>
<td>{{ s.marketingname || '-' }}</td>
<td>{{ s.pageyield ? s.pageyield.toLocaleString() : '-' }}</td>
<td class="actions">
<button class="btn btn-sm btn-secondary" @click="openModal(s)">Edit</button>
<button class="btn btn-sm btn-danger" @click="remove(s)">Delete</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty-state">No supplies mapped yet. Add one above.</p>
</div>
</template>
</div>
</div>
<!-- add / edit modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<h3>{{ editing ? 'Edit Supply' : 'Add Supply' }}</h3>
<div class="form-grid">
<label>
Supply Type
<select v-model="form.supplytype" class="form-control" @change="onSupplyTypeChange">
<option v-for="t in meta.supplytypes" :key="t" :value="t">{{ t }}</option>
</select>
</label>
<label>
Color
<select v-model="form.color" class="form-control">
<option v-for="c in meta.colors" :key="c" :value="c">{{ c }}</option>
</select>
<small v-if="form.supplytype !== 'toner'" class="field-hint">Drums/waste are often colorless - leave as "none" unless per-color</small>
</label>
<label>
Capacity Tier
<select v-model="form.capacitytier" class="form-control">
<option v-for="t in meta.capacitytiers" :key="t" :value="t">{{ t }}</option>
</select>
</label>
<label>
Part Number
<input v-model="form.partnumber" type="text" class="form-control" placeholder="e.g. W2020A" />
</label>
<label class="full">
Marketing Name
<input v-model="form.marketingname" type="text" class="form-control" placeholder="e.g. 414A Black" />
</label>
<label>
Page Yield
<input v-model.number="form.pageyield" type="number" class="form-control" placeholder="e.g. 2400" />
</label>
</div>
<label class="full">
Notes
<input v-model="form.notes" type="text" class="form-control" />
</label>
<p v-if="formError" class="text-danger">{{ formError }}</p>
<div class="modal-actions">
<button class="btn btn-secondary" @click="closeModal">Cancel</button>
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { printersApi } from '@/api'
const models = ref([])
const loadingModels = ref(true)
const search = ref('')
const onlyWithSupplies = ref(false)
const selectedModel = ref(null)
const supplies = ref([])
const loadingSupplies = ref(false)
const meta = ref({ supplytypes: [], colors: [], capacitytiers: [] })
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const formError = ref('')
const form = ref({})
let searchTimer = null
function debouncedSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(loadModels, 300)
}
async function loadModels() {
loadingModels.value = true
try {
const params = { per_page: 100 }
if (search.value) params.search = search.value
if (onlyWithSupplies.value) params.withsupplies = 'true'
const response = await printersApi.modelSupplies.listModels(params)
models.value = response.data.data || []
} finally {
loadingModels.value = false
}
}
async function selectModel(model) {
selectedModel.value = model
loadingSupplies.value = true
try {
const response = await printersApi.modelSupplies.list(model.modelnumberid)
supplies.value = response.data.data.supplies || []
} finally {
loadingSupplies.value = false
}
}
function openModal(supply = null) {
editing.value = supply
formError.value = ''
if (supply) {
form.value = { ...supply }
} else {
form.value = {
supplytype: 'toner',
color: 'black',
capacitytier: 'standard',
partnumber: '',
marketingname: '',
pageyield: null,
notes: ''
}
}
showModal.value = true
}
function onSupplyTypeChange() {
// toner is color-specific; drum/waste/maintenance usually are not. Default
// the color sensibly on type change but keep it editable (per-color drums).
if (form.value.supplytype !== 'toner') {
if (!form.value.color || form.value.color === 'black') form.value.color = 'none'
} else if (form.value.color === 'none') {
form.value.color = 'black'
}
}
function closeModal() {
showModal.value = false
editing.value = null
}
async function save() {
if (!form.value.partnumber) {
formError.value = 'Part number is required.'
return
}
saving.value = true
formError.value = ''
try {
if (editing.value) {
await printersApi.modelSupplies.update(editing.value.modelsupplyid, form.value)
} else {
await printersApi.modelSupplies.create(selectedModel.value.modelnumberid, form.value)
}
closeModal()
await selectModel(selectedModel.value)
await loadModels()
} catch (err) {
formError.value = err.response?.data?.error?.message || 'Save failed.'
} finally {
saving.value = false
}
}
async function remove(supply) {
if (!confirm(`Delete ${supply.partnumber}?`)) return
await printersApi.modelSupplies.delete(supply.modelsupplyid)
await selectModel(selectedModel.value)
await loadModels()
}
onMounted(async () => {
const metaResponse = await printersApi.modelSupplies.meta()
meta.value = metaResponse.data.data
await loadModels()
})
</script>
<style scoped>
.subtitle {
color: var(--text-light);
font-size: 0.9rem;
}
.supplies-layout {
display: grid;
grid-template-columns: 340px 1fr;
gap: 1.5rem;
align-items: start;
}
.model-panel {
padding: 1rem;
}
.withsupplies {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--text-light);
white-space: nowrap;
}
.model-list {
display: flex;
flex-direction: column;
max-height: 70vh;
overflow-y: auto;
}
.model-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.5rem;
background: none;
border: none;
border-bottom: 1px solid var(--border);
cursor: pointer;
text-align: left;
color: var(--text);
}
.model-row:hover {
background: var(--bg);
}
.model-row.active {
background: var(--primary);
color: white;
}
.model-row.active .vendor {
color: white;
}
.model-name {
font-weight: 600;
font-size: 0.9rem;
}
.model-meta {
display: flex;
align-items: center;
gap: 0.5rem;
}
.vendor {
font-size: 0.8rem;
color: var(--text-light);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.partnumber {
font-family: monospace;
}
.color-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 0.35rem;
vertical-align: middle;
}
.dot-black { background: #222; }
.dot-cyan { background: #00b7eb; }
.dot-magenta { background: #d633a0; }
.dot-yellow { background: #f0c000; }
.actions {
display: flex;
gap: 0.4rem;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
/* bg-card is intentionally translucent in dark mode; modals must be opaque */
background: var(--bg-card-solid);
padding: 1.5rem;
border-radius: 8px;
width: 520px;
max-width: 92vw;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin: 1rem 0;
}
.form-grid label,
.modal > label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--text-light);
}
.form-grid .full,
.modal > label.full {
grid-column: 1 / -1;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.field-hint {
font-size: 0.75rem;
color: var(--text-light);
margin-top: 0.2rem;
}
.text-danger {
color: var(--danger);
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-light);
}
</style>

View File

@@ -1,177 +1,126 @@
<template> <template>
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>PC Types</h2> <h2>PC Types</h2>
<button class="btn btn-primary" @click="openModal()">+ Add PC Type</button> <button class="btn btn-primary" @click="openModal()">+ Add PC Type</button>
</div> </div>
<div class="card"> <div class="card">
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else> <template v-else>
<div class="table-container"> <div class="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>PC Type</th> <th>PC Type</th>
<th>Description</th> <th>Description</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="pt in pcTypes" :key="pt.pctypeid"> <tr v-for="pt in pcTypes" :key="pt.computertypeid">
<td>{{ pt.pctype }}</td> <td>{{ pt.computertype }}</td>
<td>{{ pt.description || '-' }}</td> <td class="cell-truncate" :title="pt.description">{{ pt.description || '-' }}</td>
<td class="actions"> <td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button> <button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(pt)">Delete</button> </td>
</td> </tr>
</tr> <tr v-if="pcTypes.length === 0">
<tr v-if="pcTypes.length === 0"> <td colspan="3" style="text-align: center; color: var(--text-light);">
<td colspan="3" style="text-align: center; color: var(--text-light);"> No PC types found
No PC types found </td>
</td> </tr>
</tr> </tbody>
</tbody> </table>
</table> </div>
</div> </template>
</div>
<!-- Pagination -->
<PaginationBar <!-- Add/Edit Modal -->
:page="page" <div v-if="showModal" class="modal-overlay" @click.self="closeModal">
:totalPages="totalPages" <div class="modal">
:perPage="perPage" <div class="modal-header">
@update:page="goToPage" <h3>{{ editing ? 'Edit PC Type' : 'Add PC Type' }}</h3>
@update:perPage="changePerPage" </div>
/> <form @submit.prevent="save">
</template> <div class="modal-body">
</div> <div class="form-group">
<label for="computertype">PC Type *</label>
<!-- Add/Edit Modal --> <input id="computertype" v-model="form.computertype" type="text" class="form-control" required />
<div v-if="showModal" class="modal-overlay" @click.self="closeModal"> </div>
<div class="modal"> <div class="form-group">
<div class="modal-header"> <label for="description">Description</label>
<h3>{{ editing ? 'Edit PC Type' : 'Add PC Type' }}</h3> <textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
</div> </div>
<form @submit.prevent="save"> <div v-if="error" class="error-message">{{ error }}</div>
<div class="modal-body"> </div>
<div class="form-group"> <div class="modal-footer">
<label for="pctype">PC Type *</label> <button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<input id="pctype" v-model="form.pctype" type="text" class="form-control" required /> <button type="submit" class="btn btn-primary" :disabled="saving">
</div> {{ saving ? 'Saving...' : 'Save' }}
<div class="form-group"> </button>
<label for="description">Description</label> </div>
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea> </form>
</div> </div>
<div v-if="error" class="error-message">{{ error }}</div> </div>
</div> </div>
<div class="modal-footer"> </template>
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving"> <script setup>
{{ saving ? 'Saving...' : 'Save' }} import { ref, onMounted } from 'vue'
</button> import { computersApi } from '../../api'
</div>
</form> const pcTypes = ref([])
</div> const loading = ref(true)
</div>
const showModal = ref(false)
<!-- Delete Modal --> const editing = ref(null)
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false"> const saving = ref(false)
<div class="modal"> const error = ref('')
<div class="modal-header"><h3>Delete PC Type</h3></div>
<div class="modal-body"> const form = ref({ computertype: '', description: '' })
<p>Are you sure you want to delete <strong>{{ toDelete?.pctype }}</strong>?</p>
</div> onMounted(() => loadData())
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button> async function loadData() {
<button class="btn btn-danger" @click="deleteItem">Delete</button> loading.value = true
</div> try {
</div> const response = await computersApi.types.list({ perpage: 100 })
</div> pcTypes.value = response.data.data || []
</div> } catch (err) {
</template> console.error('Error loading PC types:', err)
} finally {
<script setup> loading.value = false
import { ref, onMounted } from 'vue' }
import { pctypesApi } from '../../api' }
import PaginationBar from '../../components/PaginationBar.vue'
function openModal(item = null) {
const pcTypes = ref([]) editing.value = item
const loading = ref(true) form.value = item
const page = ref(1) ? { computertype: item.computertype || '', description: item.description || '' }
const totalPages = ref(1) : { computertype: '', description: '' }
const perPage = ref(20) error.value = ''
showModal.value = true
const showModal = ref(false) }
const editing = ref(null)
const saving = ref(false) function closeModal() { showModal.value = false; editing.value = null }
const error = ref('')
async function save() {
const showDeleteModal = ref(false) error.value = ''
const toDelete = ref(null) saving.value = true
try {
const form = ref({ pctype: '', description: '' }) if (editing.value) {
await computersApi.types.update(editing.value.computertypeid, form.value)
onMounted(() => loadData()) } else {
await computersApi.types.create(form.value)
async function loadData() { }
loading.value = true closeModal()
try { loadData()
const response = await pctypesApi.list({ page: page.value, perpage: perPage.value }) } catch (err) {
pcTypes.value = response.data.data || [] error.value = err.response?.data?.message || 'Failed to save'
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } finally {
} catch (err) { saving.value = false
console.error('Error loading PC types:', err) }
} finally { }
loading.value = false </script>
}
}
function goToPage(p) { page.value = p; loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadData()
}
function openModal(item = null) {
editing.value = item
form.value = item ? { pctype: item.pctype || '', description: item.description || '' } : { pctype: '', description: '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await pctypesApi.update(editing.value.pctypeid, form.value)
} else {
await pctypesApi.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
async function deleteItem() {
try {
await pctypesApi.delete(toDelete.value.pctypeid)
showDeleteModal.value = false
toDelete.value = null
loadData()
} catch (err) {
alert('Failed to delete')
}
}
</script>

View File

@@ -0,0 +1,140 @@
<template>
<div>
<div class="page-header">
<h2>Plugins</h2>
<span v-if="contractVersion" class="contract-badge">contract v{{ contractVersion }}</span>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<p class="hint">
Disabling a plugin removes its pages and API on the next app restart.
</p>
<div class="table-container">
<table>
<thead>
<tr>
<th>Plugin</th>
<th>Version</th>
<th>Description</th>
<th>Enabled</th>
</tr>
</thead>
<tbody>
<tr v-for="p in plugins" :key="p.name">
<td>{{ p.name }}</td>
<td class="mono">{{ p.version }}</td>
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
<td>
<button
class="toggle-btn"
:class="{ active: p.enabled }"
:disabled="saving"
@click="toggle(p)"
>
<span class="toggle-slider"></span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="message" class="success-message">{{ message }}</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { pluginsApi } from '../../api'
const plugins = ref([])
const contractVersion = ref('')
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const message = ref('')
onMounted(() => load())
async function load() {
loading.value = true
try {
const response = await pluginsApi.list()
plugins.value = response.data.data.plugins || []
contractVersion.value = response.data.data.contract_version || ''
} catch (err) {
error.value = 'Failed to load plugins'
} finally {
loading.value = false
}
}
async function toggle(p) {
error.value = ''
message.value = ''
saving.value = true
try {
const response = await pluginsApi.setEnabled(p.name, !p.enabled)
p.enabled = !p.enabled
message.value = response.data.message || 'Updated'
} catch (err) {
error.value = err.response?.data?.data?.error?.message || 'Failed to update plugin'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.hint {
color: var(--text-light);
margin-bottom: 1rem;
}
.contract-badge {
font-size: 0.8rem;
color: var(--text-light);
background: var(--bg);
padding: 0.25rem 0.6rem;
border-radius: 4px;
}
.toggle-btn {
width: 44px;
height: 24px;
border-radius: 12px;
border: none;
background: var(--border);
cursor: pointer;
position: relative;
transition: background 0.2s;
}
.toggle-btn.active {
background: var(--success);
}
.toggle-slider {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s;
}
.toggle-btn.active .toggle-slider {
transform: translateX(20px);
}
</style>

View File

@@ -27,6 +27,12 @@
<p>Manage equipment models by vendor</p> <p>Manage equipment models by vendor</p>
</router-link> </router-link>
<router-link to="/settings/modelsupplies" class="settings-card">
<div class="card-icon"><Droplets :size="28" /></div>
<h3>Model Toners and Supplies</h3>
<p>Map toner, drum, and waste part numbers to printer models</p>
</router-link>
<router-link to="/settings/machinetypes" class="settings-card"> <router-link to="/settings/machinetypes" class="settings-card">
<div class="card-icon"><Monitor :size="28" /></div> <div class="card-icon"><Monitor :size="28" /></div>
<h3>Machine Types</h3> <h3>Machine Types</h3>
@@ -69,6 +75,12 @@
<p>Configure integrations and system options</p> <p>Configure integrations and system options</p>
</router-link> </router-link>
<router-link to="/settings/plugins" class="settings-card">
<div class="card-icon"><Puzzle :size="28" /></div>
<h3>Plugins</h3>
<p>Enable or disable installed plugins</p>
</router-link>
<router-link to="/settings/auditlogs" class="settings-card"> <router-link to="/settings/auditlogs" class="settings-card">
<div class="card-icon"><FileText :size="28" /></div> <div class="card-icon"><FileText :size="28" /></div>
<h3>Audit Logs</h3> <h3>Audit Logs</h3>
@@ -85,7 +97,7 @@
</template> </template>
<script setup> <script setup>
import { Factory, MapPin, Tag, Package, Monitor, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users } from 'lucide-vue-next' import { Factory, MapPin, Tag, Package, Droplets, Monitor, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle } from 'lucide-vue-next'
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>Machine Statuses</h2> <h2>Asset Statuses</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Status</button> <button class="btn btn-primary" @click="openModal()">+ Add Status</button>
</div> </div>
@@ -30,7 +30,7 @@
<span class="color-preview" :style="{ backgroundColor: s.color || '#6c757d' }"></span> <span class="color-preview" :style="{ backgroundColor: s.color || '#6c757d' }"></span>
{{ s.color || 'default' }} {{ s.color || 'default' }}
</td> </td>
<td>{{ s.description || '-' }}</td> <td class="cell-truncate" :title="s.description">{{ s.description || '-' }}</td>
<td class="actions"> <td class="actions">
<button <button
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"
@@ -135,7 +135,7 @@
<div class="modal-body"> <div class="modal-body">
<p>Are you sure you want to delete <strong>{{ statusToDelete?.status }}</strong>?</p> <p>Are you sure you want to delete <strong>{{ statusToDelete?.status }}</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;"> <p style="color: var(--text-light); font-size: 0.875rem;">
Cannot delete if machines are using this status. Cannot delete if assets are using this status.
</p> </p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -149,7 +149,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { statusesApi } from '../../api' import { assetsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue' import PaginationBar from '../../components/PaginationBar.vue'
const statuses = ref([]) const statuses = ref([])
@@ -184,7 +184,7 @@ async function loadStatuses() {
perpage: perPage.value perpage: perPage.value
} }
const response = await statusesApi.list(params) const response = await assetsApi.statuses.list(params)
statuses.value = response.data.data || [] statuses.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) { } catch (err) {
@@ -235,9 +235,9 @@ async function saveStatus() {
try { try {
if (editingStatus.value) { if (editingStatus.value) {
await statusesApi.update(editingStatus.value.statusid, form.value) await assetsApi.statuses.update(editingStatus.value.statusid, form.value)
} else { } else {
await statusesApi.create(form.value) await assetsApi.statuses.create(form.value)
} }
closeModal() closeModal()
loadStatuses() loadStatuses()
@@ -256,7 +256,7 @@ function confirmDelete(s) {
async function deleteStatus() { async function deleteStatus() {
try { try {
await statusesApi.delete(statusToDelete.value.statusid) await assetsApi.statuses.delete(statusToDelete.value.statusid)
showDeleteModal.value = false showDeleteModal.value = false
statusToDelete.value = null statusToDelete.value = null
loadStatuses() loadStatuses()

View File

@@ -375,6 +375,45 @@
</router-link> </router-link>
</div> </div>
</div> </div>
<!-- Asset Identifiers Section -->
<div class="section-card">
<h2 class="section-title">Asset Identifiers</h2>
<div class="setting-group">
<p class="setting-description">
Enable or disable optional asset identifiers per asset type. When disabled
for a type, the identifier is hidden from that type's forms and detail
pages across the system.
</p>
<div class="table-container">
<table class="identifier-matrix">
<thead>
<tr>
<th>Identifier</th>
<th v-for="col in assetTypeCols" :key="col.key">{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in identifierRows" :key="row.name">
<td class="identifier-name">{{ row.label }}</td>
<td v-for="col in assetTypeCols" :key="col.key">
<button
class="toggle-btn"
:class="{ active: matrixValue(row.name, col.key) }"
@click="toggleIdentifier(row.name, col.key)"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div> </div>
<div v-if="error" class="error-message">{{ error }}</div> <div v-if="error" class="error-message">{{ error }}</div>
@@ -384,6 +423,7 @@
<script setup> <script setup>
import { ref, reactive, onMounted, computed } from 'vue' import { ref, reactive, onMounted, computed } from 'vue'
import { settingsApi } from '../../api' import { settingsApi } from '../../api'
import { setIdentifierFlag } from '../../composables/identifierSettings'
const settings = reactive({ const settings = reactive({
// Zabbix // Zabbix
@@ -412,6 +452,30 @@ const settings = reactive({
saml_admin_group: '' saml_admin_group: ''
}) })
// Asset identifier matrix: identifier x asset type. Keys follow
// identifier_<name>_<assettype>_enabled. Missing = enabled (default on).
const identifierRows = [
{ name: 'gaugelabreference', label: 'Gauge Lab Reference' },
{ name: 'maintenancereference', label: 'Maintenance Reference' },
{ name: 'fqdn', label: 'FQDN / Hostname' }
]
const assetTypeCols = [
{ key: 'equipment', label: 'Equipment' },
{ key: 'computer', label: 'PC' },
{ key: 'printer', label: 'Printer' },
{ key: 'network_device', label: 'Network' }
]
const identifierMatrix = reactive({})
function identifierKey(name, assettype) {
return `identifier_${name}_${assettype}_enabled`
}
function matrixValue(name, assettype) {
const key = identifierKey(name, assettype)
return key in identifierMatrix ? identifierMatrix[key] : true
}
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const testingEmail = ref(false) const testingEmail = ref(false)
@@ -472,6 +536,8 @@ async function loadSettings() {
for (const setting of data.data) { for (const setting of data.data) {
if (setting.key in settings) { if (setting.key in settings) {
settings[setting.key] = setting.value settings[setting.key] = setting.value
} else if (/^identifier_.+_(equipment|computer|printer|network_device)_enabled$/.test(setting.key)) {
identifierMatrix[setting.key] = setting.value !== false
} }
} }
} catch (e) { } catch (e) {
@@ -487,6 +553,44 @@ async function toggleSetting(key) {
await saveSetting(key, newValue) await saveSetting(key, newValue)
} }
// Toggle a per-type identifier flag. The key may not be seeded yet on older
// installs, so fall back to creating it when the update returns 404.
async function toggleIdentifier(name, assettype) {
const key = identifierKey(name, assettype)
const newValue = !matrixValue(name, assettype)
try {
saving.value = true
error.value = ''
success.value = ''
try {
await settingsApi.update(key, newValue)
} catch (e) {
if (e.response?.status === 404) {
await settingsApi.create({
key,
value: newValue,
valuetype: 'boolean',
category: 'identifiers',
description: `Show the ${name} identifier on ${assettype} assets`
})
} else {
throw e
}
}
identifierMatrix[key] = newValue
// Push into the shared composable so open asset views react without a
// full page reload (the composable otherwise fetches only once).
setIdentifierFlag(name, assettype, newValue)
success.value = 'Setting saved'
setTimeout(() => { success.value = '' }, 2000)
} catch (e) {
error.value = e.response?.data?.message || 'Failed to save setting'
console.error(e)
} finally {
saving.value = false
}
}
async function saveSetting(key, value) { async function saveSetting(key, value) {
try { try {
saving.value = true saving.value = true
@@ -739,4 +843,31 @@ onMounted(loadSettings)
margin-top: -0.5rem; margin-top: -0.5rem;
margin-left: 0; margin-left: 0;
} }
.identifier-matrix {
width: 100%;
border-collapse: collapse;
}
.identifier-matrix th,
.identifier-matrix td {
padding: 0.6rem 0.75rem;
text-align: center;
border-bottom: 1px solid var(--border);
}
.identifier-matrix th:first-child,
.identifier-matrix td.identifier-name {
text-align: left;
}
.identifier-matrix th {
color: var(--text-light);
font-weight: 600;
font-size: 0.9rem;
}
.identifier-matrix .identifier-name {
color: var(--text);
}
</style> </style>

View File

@@ -87,7 +87,7 @@
{{ role.rolename }} {{ role.rolename }}
</span> </span>
</td> </td>
<td>{{ role.description || '-' }}</td> <td class="cell-truncate" :title="role.description">{{ role.description || '-' }}</td>
<td> <td>
<span v-if="role.isadmin" class="text-muted">All permissions</span> <span v-if="role.isadmin" class="text-muted">All permissions</span>
<span v-else-if="role.permissions?.length">{{ role.permissions.length }} permissions</span> <span v-else-if="role.permissions?.length">{{ role.permissions.length }} permissions</span>

View File

@@ -50,7 +50,7 @@
</span> </span>
<span v-else>-</span> <span v-else>-</span>
</td> </td>
<td>{{ vlan.description || '-' }}</td> <td class="cell-truncate" :title="vlan.description">{{ vlan.description || '-' }}</td>
<td> <td>
<router-link <router-link
:to="{ path: '/settings/subnets', query: { vlanid: vlan.vlanid } }" :to="{ path: '/settings/subnets', query: { vlanid: vlan.vlanid } }"

View File

@@ -0,0 +1,44 @@
"""Add Asset.gaugelabreference and Asset.maintenancereference
Adds dedicated external-reference columns to assets, distinct from
assetnumber. Equipment commonly carries an authoritative gauge lab reference
and a maintenance system reference; assetnumber stays the generic asset tag
and name the layperson label.
Revision ID: 7b02_gaugelabref
Revises: 7a01_adr001_position
Create Date: 2026-06-25
"""
from alembic import op
import sqlalchemy as sa
revision = '7b02_gaugelabref'
down_revision = '7a01_adr001_position'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table('assets') as batch_op:
batch_op.add_column(sa.Column(
'gaugelabreference', sa.String(length=50), nullable=True,
comment='Gauge lab asset reference (authoritative tag the gauge '
'lab assigns to equipment); distinct from assetnumber'))
batch_op.add_column(sa.Column(
'maintenancereference', sa.String(length=50), nullable=True,
comment='Maintenance system asset reference; distinct from '
'assetnumber'))
batch_op.create_index('ix_assets_gaugelabreference',
['gaugelabreference'])
batch_op.create_index('ix_assets_maintenancereference',
['maintenancereference'])
def downgrade():
with op.batch_alter_table('assets') as batch_op:
batch_op.drop_index('ix_assets_maintenancereference')
batch_op.drop_index('ix_assets_gaugelabreference')
batch_op.drop_column('maintenancereference')
batch_op.drop_column('gaugelabreference')

View File

@@ -0,0 +1,47 @@
"""Drop the legacy Machine instance layer
Retires the Machine model (ADR-001): the asset/computer model is now the
single source of truth. Drops machines + its PC/status lookups + the legacy
relationship and installed-app tables + the legacy printer extension, and
removes the deprecated communications.machineid column. machinetypes is kept
(still referenced by models.machinetypeid).
Idempotent (IF EXISTS) so it is safe even though the live drop was applied
directly during the cutover.
Revision ID: 7c01_drop_legacy_machine
Revises: 7b02_gaugelabref
Create Date: 2026-06-26
"""
from alembic import op
import sqlalchemy as sa
revision = '7c01_drop_legacy_machine'
down_revision = '7b02_gaugelabref'
branch_labels = None
depends_on = None
_TABLES = ['printerdata', 'installedapps', 'machinerelationships',
'machines', 'pctypes', 'machinestatuses']
def upgrade():
bind = op.get_bind()
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0")
insp = sa.inspect(bind)
if 'machineid' in [c['name'] for c in insp.get_columns('communications')]:
for fk in insp.get_foreign_keys('communications'):
if 'machineid' in fk['constrained_columns'] and fk.get('name'):
bind.exec_driver_sql(
f"ALTER TABLE communications DROP FOREIGN KEY {fk['name']}")
op.drop_column('communications', 'machineid')
for t in _TABLES:
bind.exec_driver_sql(f"DROP TABLE IF EXISTS {t}")
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1")
def downgrade():
# The Machine layer is retired; recreating it is out of scope.
raise NotImplementedError("Legacy Machine layer cannot be restored")

View File

@@ -0,0 +1,29 @@
"""Add applications.isrequired
Backs the software-compliance report, which compares required applications
against what is installed per PC. There was no such flag before, so the report
errored.
Revision ID: 7c02_app_isrequired
Revises: 7c01_drop_legacy_machine
Create Date: 2026-06-26
"""
from alembic import op
import sqlalchemy as sa
revision = '7c02_app_isrequired'
down_revision = '7c01_drop_legacy_machine'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('applications', sa.Column(
'isrequired', sa.Boolean(), nullable=True, server_default=sa.false(),
comment='Required on all PCs (drives the software-compliance report)'))
def downgrade():
op.drop_column('applications', 'isrequired')

View File

@@ -0,0 +1,56 @@
"""Add locationtypes + locations tree (ADR-001)
Creates the locationtypes lookup and extends locations with locationtypeid +
parentlocationid (self-FK), so sites can classify locations and build a
location tree (sections, cells, sub-cells, operations, etc.). Seeds the
canonical location types.
Revision ID: 7c03_locationtypes
Revises: 7c02_app_isrequired
Create Date: 2026-06-26
"""
from alembic import op
import sqlalchemy as sa
revision = '7c03_locationtypes'
down_revision = '7c02_app_isrequired'
branch_labels = None
depends_on = None
_TYPES = ['section', 'cell', 'subcell', 'operation', 'meetingroom', 'lab',
'office', 'storage', 'hallway', 'networkcloset', 'building']
def upgrade():
op.create_table(
'locationtypes',
sa.Column('locationtypeid', sa.Integer(), primary_key=True),
sa.Column('locationtype', sa.String(length=50), nullable=False, unique=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=True),
sa.Column('modifieddate', sa.DateTime(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=True),
)
with op.batch_alter_table('locations') as batch_op:
batch_op.add_column(sa.Column('locationtypeid', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('parentlocationid', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_locations_locationtype', 'locationtypes',
['locationtypeid'], ['locationtypeid'])
batch_op.create_foreign_key('fk_locations_parent', 'locations',
['parentlocationid'], ['locationid'])
lt = sa.table('locationtypes',
sa.column('locationtype', sa.String),
sa.column('isactive', sa.Boolean))
op.bulk_insert(lt, [{'locationtype': t, 'isactive': True} for t in _TYPES])
def downgrade():
with op.batch_alter_table('locations') as batch_op:
batch_op.drop_constraint('fk_locations_parent', type_='foreignkey')
batch_op.drop_constraint('fk_locations_locationtype', type_='foreignkey')
batch_op.drop_column('parentlocationid')
batch_op.drop_column('locationtypeid')
op.drop_table('locationtypes')

View File

@@ -0,0 +1,85 @@
"""Fold remaining plugin schema into the core chain
Deploys run `flask db upgrade` (core chain) only, so the core chain must
reproduce the full bundled schema. The core baseline already creates the
plugin tables as of baseline time; this adds the post-baseline additions that
previously lived only in per-plugin migrations (which deploys don't run):
- modelsupplies (printer model -> toner/drum/waste part numbers)
- computers.vendorid + computers.modelnumberid (PC make/model)
- computerinstalledapps.installedversion (collector version string)
Idempotent: skips anything already present, so it is a no-op on the live DB
(which already has these) and creates them on a fresh deploy.
Revision ID: 7c04_fold_plugin_schema
Revises: 7c03_locationtypes
Create Date: 2026-06-26
"""
from alembic import op
import sqlalchemy as sa
revision = '7c04_fold_plugin_schema'
down_revision = '7c03_locationtypes'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
tables = set(insp.get_table_names())
if 'modelsupplies' not in tables:
op.create_table(
'modelsupplies',
sa.Column('modelsupplyid', sa.Integer(), primary_key=True),
sa.Column('modelnumberid', sa.Integer(),
sa.ForeignKey('models.modelnumberid'), nullable=False),
sa.Column('supplytype', sa.String(length=20), nullable=False,
server_default='toner'),
sa.Column('color', sa.String(length=20), nullable=False,
server_default='none'),
sa.Column('capacitytier', sa.String(length=20), nullable=False,
server_default='standard'),
sa.Column('partnumber', sa.String(length=50), nullable=False),
sa.Column('marketingname', sa.String(length=120), nullable=True),
sa.Column('pageyield', sa.Integer(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=True),
sa.Column('modifieddate', sa.DateTime(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=True),
sa.UniqueConstraint('modelnumberid', 'partnumber',
name='uq_modelsupply_part'),
)
op.create_index('idx_modelsupplies_modelnumberid', 'modelsupplies',
['modelnumberid'])
comp_cols = {c['name'] for c in insp.get_columns('computers')}
with op.batch_alter_table('computers') as batch_op:
if 'vendorid' not in comp_cols:
batch_op.add_column(sa.Column('vendorid', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_computers_vendor', 'vendors',
['vendorid'], ['vendorid'])
if 'modelnumberid' not in comp_cols:
batch_op.add_column(sa.Column('modelnumberid', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_computers_model', 'models',
['modelnumberid'], ['modelnumberid'])
cia_cols = {c['name'] for c in insp.get_columns('computerinstalledapps')}
if 'installedversion' not in cia_cols:
op.add_column('computerinstalledapps',
sa.Column('installedversion', sa.String(length=100),
nullable=True))
def downgrade():
op.drop_table('modelsupplies')
with op.batch_alter_table('computers') as batch_op:
batch_op.drop_constraint('fk_computers_model', type_='foreignkey')
batch_op.drop_constraint('fk_computers_vendor', type_='foreignkey')
batch_op.drop_column('modelnumberid')
batch_op.drop_column('vendorid')
op.drop_column('computerinstalledapps', 'installedversion')

View File

@@ -3,15 +3,7 @@
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db 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 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 ..models import Computer, ComputerType, ComputerInstalledApp from ..models import Computer, ComputerType, ComputerInstalledApp
@@ -162,19 +154,19 @@ def list_computers():
) )
# Computer type filter # 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)) query = query.filter(Computer.computertypeid == int(type_id))
# OS filter # 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)) query = query.filter(Computer.osid == int(os_id))
# Location filter # 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)) query = query.filter(Asset.locationid == int(location_id))
# Business unit filter # 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)) query = query.filter(Asset.businessunitid == int(bu_id))
# Shopfloor filter # Shopfloor filter
@@ -225,6 +217,10 @@ def get_computer(computer_id: int):
result = comp.asset.to_dict() if comp.asset else {} result = comp.asset.to_dict() if comp.asset else {}
result['computer'] = comp.to_dict() 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) return success_response(result)
@@ -321,6 +317,8 @@ def create_computer():
assetnumber=data['assetnumber'], assetnumber=data['assetnumber'],
name=data.get('name'), name=data.get('name'),
serialnumber=data.get('serialnumber'), serialnumber=data.get('serialnumber'),
gaugelabreference=data.get('gaugelabreference'),
maintenancereference=data.get('maintenancereference'),
assettypeid=computer_type.assettypeid, assettypeid=computer_type.assettypeid,
statusid=data.get('statusid', 1), statusid=data.get('statusid', 1),
locationid=data.get('locationid'), locationid=data.get('locationid'),
@@ -339,6 +337,8 @@ def create_computer():
computertypeid=data.get('computertypeid'), computertypeid=data.get('computertypeid'),
hostname=data.get('hostname'), hostname=data.get('hostname'),
osid=data.get('osid'), osid=data.get('osid'),
vendorid=data.get('vendorid'),
modelnumberid=data.get('modelnumberid'),
loggedinuser=data.get('loggedinuser'), loggedinuser=data.get('loggedinuser'),
lastreporteddate=data.get('lastreporteddate'), lastreporteddate=data.get('lastreporteddate'),
lastboottime=data.get('lastboottime'), lastboottime=data.get('lastboottime'),
@@ -350,6 +350,17 @@ def create_computer():
db.session.add(comp) db.session.add(comp)
db.session.flush() 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 # Audit log
AuditLog.log('created', 'Computer', entityid=comp.computerid, AuditLog.log('created', 'Computer', entityid=comp.computerid,
entityname=data.get('hostname') or data['assetnumber']) entityname=data.get('hostname') or data['assetnumber'])
@@ -404,7 +415,8 @@ def update_computer(computer_id: int):
changes = {} changes = {}
# Update asset fields # Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
'maintenancereference', 'statusid',
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
for key in asset_fields: for key in asset_fields:
if key in data: if key in data:
@@ -415,8 +427,9 @@ def update_computer(computer_id: int):
setattr(asset, key, data[key]) setattr(asset, key, data[key])
# Update computer fields # Update computer fields
computer_fields = ['computertypeid', 'hostname', 'osid', 'loggedinuser', computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid',
'lastreporteddate', 'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor'] 'modelnumberid', 'loggedinuser', 'lastreporteddate',
'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor']
for key in computer_fields: for key in computer_fields:
if key in data: if key in data:
old_val = getattr(comp, key) old_val = getattr(comp, key)
@@ -425,6 +438,23 @@ def update_computer(computer_id: int):
changes[key] = {'old': old_val, 'new': new_val} changes[key] = {'old': old_val, 'new': new_val}
setattr(comp, key, data[key]) 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 # Audit log if there were changes
if changes: if changes:
AuditLog.log('updated', 'Computer', entityid=comp.computerid, AuditLog.log('updated', 'Computer', entityid=comp.computerid,

View File

@@ -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

View File

@@ -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()

View File

@@ -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"}

View File

@@ -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')

View File

@@ -1,184 +1,203 @@
"""Computer plugin models.""" """Computer plugin models."""
from shopdb.extensions import db from shopdb.api import db, BaseModel
from shopdb.core.models.base import BaseModel
class ComputerType(BaseModel):
class ComputerType(BaseModel): """
""" Computer type classification.
Computer type classification.
Examples: Shopfloor PC, Engineer Workstation, CMM PC, Server, etc.
Examples: Shopfloor PC, Engineer Workstation, CMM PC, Server, etc. """
""" __tablename__ = 'computertypes'
__tablename__ = 'computertypes'
computertypeid = db.Column(db.Integer, primary_key=True)
computertypeid = db.Column(db.Integer, primary_key=True) computertype = db.Column(db.String(100), unique=True, nullable=False)
computertype = db.Column(db.String(100), unique=True, nullable=False) description = db.Column(db.Text)
description = db.Column(db.Text) icon = db.Column(db.String(50), comment='Icon name for UI')
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
def __repr__(self): return f"<ComputerType {self.computertype}>"
return f"<ComputerType {self.computertype}>"
class Computer(BaseModel):
class Computer(BaseModel): """
""" Computer-specific extension data.
Computer-specific extension data.
Links to core Asset table via assetid.
Links to core Asset table via assetid. Stores computer-specific fields like hostname, OS, logged in user, etc.
Stores computer-specific fields like hostname, OS, logged in user, etc. """
""" __tablename__ = 'computers'
__tablename__ = 'computers'
computerid = db.Column(db.Integer, primary_key=True)
computerid = db.Column(db.Integer, primary_key=True)
# Link to core asset
# Link to core asset assetid = db.Column(
assetid = db.Column( db.Integer,
db.Integer, db.ForeignKey('assets.assetid', ondelete='CASCADE'),
db.ForeignKey('assets.assetid', ondelete='CASCADE'), unique=True,
unique=True, nullable=False,
nullable=False, index=True
index=True )
)
# Computer classification
# Computer classification computertypeid = db.Column(
computertypeid = db.Column( db.Integer,
db.Integer, db.ForeignKey('computertypes.computertypeid'),
db.ForeignKey('computertypes.computertypeid'), nullable=True
nullable=True )
)
# Network identity
# Network identity hostname = db.Column(
hostname = db.Column( db.String(100),
db.String(100), index=True,
index=True, comment='Network hostname'
comment='Network hostname' )
)
# Operating system
# Operating system osid = db.Column(
osid = db.Column( db.Integer,
db.Integer, db.ForeignKey('operatingsystems.osid'),
db.ForeignKey('operatingsystems.osid'), nullable=True
nullable=True )
)
# Hardware make/model (PCs carry vendor + model like equipment)
# Status tracking vendorid = db.Column(
loggedinuser = db.Column(db.String(100), nullable=True) db.Integer,
lastreporteddate = db.Column(db.DateTime, nullable=True) db.ForeignKey('vendors.vendorid'),
lastboottime = db.Column(db.DateTime, nullable=True) nullable=True
)
# Remote access features modelnumberid = db.Column(
isvnc = db.Column( db.Integer,
db.Boolean, db.ForeignKey('models.modelnumberid'),
default=False, nullable=True
comment='VNC remote access enabled' )
)
iswinrm = db.Column( # Status tracking
db.Boolean, loggedinuser = db.Column(db.String(100), nullable=True)
default=False, lastreporteddate = db.Column(db.DateTime, nullable=True)
comment='WinRM enabled' lastboottime = db.Column(db.DateTime, nullable=True)
)
# Remote access features
# Classification flags isvnc = db.Column(
isshopfloor = db.Column( db.Boolean,
db.Boolean, default=False,
default=False, comment='VNC remote access enabled'
comment='Shopfloor PC (vs office PC)' )
) iswinrm = db.Column(
db.Boolean,
# Relationships default=False,
asset = db.relationship( comment='WinRM enabled'
'Asset', )
backref=db.backref('computer', uselist=False, lazy='joined')
) # Classification flags
computertype = db.relationship('ComputerType', backref='computers') isshopfloor = db.Column(
operatingsystem = db.relationship('OperatingSystem', backref='computers') db.Boolean,
default=False,
# Installed applications (one-to-many) comment='Shopfloor PC (vs office PC)'
installedapps = db.relationship( )
'ComputerInstalledApp',
back_populates='computer', # Relationships
cascade='all, delete-orphan', asset = db.relationship(
lazy='dynamic' 'Asset',
) backref=db.backref('computer', uselist=False, lazy='joined')
)
__table_args__ = ( computertype = db.relationship('ComputerType', backref='computers')
db.Index('idx_computer_type', 'computertypeid'), operatingsystem = db.relationship('OperatingSystem', backref='computers')
db.Index('idx_computer_hostname', 'hostname'), vendor = db.relationship('Vendor')
db.Index('idx_computer_os', 'osid'), model = db.relationship('Model')
)
# Installed applications (one-to-many)
def __repr__(self): installedapps = db.relationship(
return f"<Computer {self.hostname or self.assetid}>" 'ComputerInstalledApp',
back_populates='computer',
def to_dict(self): cascade='all, delete-orphan',
"""Convert to dictionary with related names.""" lazy='dynamic'
result = super().to_dict() )
# Add related object names __table_args__ = (
if self.computertype: db.Index('idx_computer_type', 'computertypeid'),
result['computertypename'] = self.computertype.computertype db.Index('idx_computer_hostname', 'hostname'),
if self.operatingsystem: db.Index('idx_computer_os', 'osid'),
result['osname'] = self.operatingsystem.osname )
return result def __repr__(self):
return f"<Computer {self.hostname or self.assetid}>"
class ComputerInstalledApp(db.Model): def to_dict(self):
""" """Convert to dictionary with related names."""
Junction table for applications installed on computers. result = super().to_dict()
Tracks which applications are installed on which computers, # Add related object names
including version information. if self.computertype:
""" result['computertypename'] = self.computertype.computertype
__tablename__ = 'computerinstalledapps' if self.operatingsystem:
result['osname'] = self.operatingsystem.osname
id = db.Column(db.Integer, primary_key=True) if self.vendor:
computerid = db.Column( result['vendorname'] = self.vendor.vendor
db.Integer, if self.model:
db.ForeignKey('computers.computerid', ondelete='CASCADE'), result['modelname'] = self.model.modelnumber
nullable=False
) return result
appid = db.Column(
db.Integer,
db.ForeignKey('applications.appid'), class ComputerInstalledApp(db.Model):
nullable=False """
) Junction table for applications installed on computers.
appversionid = db.Column(
db.Integer, Tracks which applications are installed on which computers,
db.ForeignKey('appversions.appversionid'), including version information.
nullable=True """
) __tablename__ = 'computerinstalledapps'
isactive = db.Column(db.Boolean, default=True, nullable=False)
installeddate = db.Column(db.DateTime, default=db.func.now()) id = db.Column(db.Integer, primary_key=True)
computerid = db.Column(
# Relationships db.Integer,
computer = db.relationship('Computer', back_populates='installedapps') db.ForeignKey('computers.computerid', ondelete='CASCADE'),
application = db.relationship('Application') nullable=False
appversion = db.relationship('AppVersion') )
appid = db.Column(
__table_args__ = ( db.Integer,
db.UniqueConstraint('computerid', 'appid', name='uq_computer_app'), db.ForeignKey('applications.appid'),
db.Index('idx_compapp_computer', 'computerid'), nullable=False
db.Index('idx_compapp_app', 'appid'), )
) appversionid = db.Column(
db.Integer,
def to_dict(self): db.ForeignKey('appversions.appversionid'),
"""Convert to dictionary.""" nullable=True
return { )
'id': self.id, # Raw version string from automated collection (when no curated AppVersion)
'computerid': self.computerid, installedversion = db.Column(db.String(100), nullable=True)
'appid': self.appid, isactive = db.Column(db.Boolean, default=True, nullable=False)
'appversionid': self.appversionid, installeddate = db.Column(db.DateTime, default=db.func.now())
'isactive': self.isactive,
'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None, # Relationships
'application': { computer = db.relationship('Computer', back_populates='installedapps')
'appid': self.application.appid, application = db.relationship('Application')
'appname': self.application.appname, appversion = db.relationship('AppVersion')
'appdescription': self.application.appdescription,
} if self.application else None, __table_args__ = (
'version': self.appversion.version if self.appversion else None db.UniqueConstraint('computerid', 'appid', name='uq_computer_app'),
} db.Index('idx_compapp_computer', 'computerid'),
db.Index('idx_compapp_app', 'appid'),
def __repr__(self): )
return f"<ComputerInstalledApp computer={self.computerid} app={self.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"<ComputerInstalledApp computer={self.computerid} app={self.appid}>"

View File

@@ -1,209 +1,306 @@
"""Computers plugin main class.""" """Computers plugin main class."""
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional, Type from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint from flask import Flask, Blueprint
import click import click
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.api import db, AssetType
from shopdb.core.models import AssetType, AssetStatus
from .models import Computer, ComputerType, ComputerInstalledApp
from .models import Computer, ComputerType, ComputerInstalledApp from .api import computers_bp
from .api import computers_bp
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
class ComputersPlugin(BasePlugin):
class ComputersPlugin(BasePlugin): """
""" Computers plugin - manages PC, server, and workstation assets.
Computers plugin - manages PC, server, and workstation assets.
Computers include shopfloor PCs, engineer workstations, servers, etc.
Computers include shopfloor PCs, engineer workstations, servers, etc. Uses the new Asset architecture with Computer extension table.
Uses the new Asset architecture with Computer extension table. """
"""
def __init__(self):
def __init__(self): self._manifest = self._load_manifest()
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
def _load_manifest(self) -> Dict: """Load plugin manifest from JSON file."""
"""Load plugin manifest from JSON file.""" manifestpath = Path(__file__).parent / 'manifest.json'
manifestpath = Path(__file__).parent / 'manifest.json' if manifestpath.exists():
if manifestpath.exists(): with open(manifestpath, 'r') as f:
with open(manifestpath, 'r') as f: return json.load(f)
return json.load(f) return {}
return {}
@property
@property def meta(self) -> PluginMeta:
def meta(self) -> PluginMeta: """Return plugin metadata."""
"""Return plugin metadata.""" return PluginMeta(
return PluginMeta( name=self._manifest.get('name', 'computers'),
name=self._manifest.get('name', 'computers'), version=self._manifest.get('version', '1.0.0'),
version=self._manifest.get('version', '1.0.0'), description=self._manifest.get(
description=self._manifest.get( 'description',
'description', 'Computer management for PCs, servers, and workstations'
'Computer management for PCs, servers, and workstations' ),
), author=self._manifest.get('author', 'ShopDB Team'),
author=self._manifest.get('author', 'ShopDB Team'), dependencies=self._manifest.get('dependencies', []),
dependencies=self._manifest.get('dependencies', []), core_version=self._manifest.get('core_version', '>=1.0.0'),
core_version=self._manifest.get('core_version', '>=1.0.0'), api_prefix=self._manifest.get('api_prefix', '/api/computers'),
api_prefix=self._manifest.get('api_prefix', '/api/computers'), )
)
def get_blueprint(self) -> Optional[Blueprint]:
def get_blueprint(self) -> Optional[Blueprint]: """Return Flask Blueprint with API routes."""
"""Return Flask Blueprint with API routes.""" return computers_bp
return computers_bp
def get_models(self) -> List[Type]:
def get_models(self) -> List[Type]: """Return list of SQLAlchemy model classes."""
"""Return list of SQLAlchemy model classes.""" return [Computer, ComputerType, ComputerInstalledApp]
return [Computer, ComputerType, ComputerInstalledApp]
def init_app(self, app: Flask, db_instance) -> None:
def init_app(self, app: Flask, db_instance) -> None: """Initialize plugin with Flask app."""
"""Initialize plugin with Flask app.""" logger.info(f"Computers plugin initialized (v{self.meta.version})")
logger.info(f"Computers plugin initialized (v{self.meta.version})")
# -- ADR-006 collector contract -----------------------------------------
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed.""" def get_collector_schema(self) -> Optional[Dict]:
with app.app_context(): """Schema for the PC collector payload (matched by hostname)."""
self._ensure_asset_type() return {
self._ensure_computer_types() 'identityfield': 'hostname',
logger.info("Computers plugin installed") 'fields': {
'hostname': {'type': 'string', 'required': True},
def _ensure_asset_type(self) -> None: 'serialnumber': {'type': 'string'},
"""Ensure computer asset type exists.""" 'currentuser': {'type': 'string'},
existing = AssetType.query.filter_by(assettype='computer').first() 'lastboottime': {'type': 'string', 'format': 'date-time'},
if not existing: 'ipaddress': {'type': 'string'},
at = AssetType( 'installedsoftware': {
assettype='computer', 'type': 'array',
pluginname='computers', 'items': {'name': 'string', 'version': 'string'},
tablename='computers', },
description='PCs, servers, and workstations', },
icon='desktop' }
)
db.session.add(at) def apply_collector_payload(self, payload: Dict) -> Dict:
logger.debug("Created asset type: computer") """Idempotent upsert of a PC from a collector payload (by hostname)."""
db.session.commit() from datetime import datetime
from shopdb.api import Asset, Application, Communication, CommunicationType
def _ensure_computer_types(self) -> None:
"""Ensure basic computer types exist.""" warnings = []
computer_types = [ hostname = (payload.get('hostname') or '').strip()
('Shopfloor PC', 'PC located on the shop floor for machine operation', 'desktop'), if not hostname:
('Engineer Workstation', 'Engineering workstation for CAD/CAM work', 'laptop'), raise ValueError('hostname is required')
('CMM PC', 'PC dedicated to CMM operation', 'desktop'),
('Server', 'Server system', 'server'), comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
('Kiosk', 'Kiosk or info display PC', 'tv'), if not comp:
('Laptop', 'Laptop computer', 'laptop'), comp = (Computer.query.join(Asset, Asset.assetid == Computer.assetid)
('Virtual Machine', 'Virtual machine', 'cloud'), .filter(Asset.assetnumber.ilike(hostname)).first())
('Other', 'Other computer type', 'desktop'),
] action = 'updated'
if not comp:
for name, description, icon in computer_types: atype = AssetType.query.filter_by(assettype='computer').first()
existing = ComputerType.query.filter_by(computertype=name).first() # statusid=1 is the first seeded asset status ("In Use"); a
if not existing: # collector-discovered PC is by definition in use.
ct = ComputerType( asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
computertype=name, statusid=1)
description=description, db.session.add(asset)
icon=icon db.session.flush()
) comp = Computer(assetid=asset.assetid, hostname=hostname)
db.session.add(ct) db.session.add(comp)
logger.debug(f"Created computer type: {name}") db.session.flush()
action = 'created'
db.session.commit()
comp.lastreporteddate = datetime.utcnow()
def on_uninstall(self, app: Flask) -> None: if payload.get('lastboottime'):
"""Called when plugin is uninstalled.""" try:
logger.info("Computers plugin uninstalled") comp.lastboottime = datetime.fromisoformat(
payload['lastboottime'].replace('Z', '+00:00'))
def get_cli_commands(self) -> List: except (ValueError, AttributeError):
"""Return CLI commands for this plugin.""" warnings.append('lastboottime not parseable')
if payload.get('currentuser'):
@click.group('computers') comp.loggedinuser = payload['currentuser']
def computerscli(): if payload.get('serialnumber') and comp.asset:
"""Computers plugin commands.""" comp.asset.serialnumber = payload['serialnumber']
pass
if payload.get('ipaddress'):
@computerscli.command('list-types') ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
def list_types(): primary = Communication.query.filter_by(
"""List all computer types.""" assetid=comp.assetid, isprimary=True).first()
from flask import current_app if primary:
primary.ipaddress = payload['ipaddress']
with current_app.app_context(): elif ip_comtype:
types = ComputerType.query.filter_by(isactive=True).all() db.session.add(Communication(
if not types: assetid=comp.assetid, comtypeid=ip_comtype.comtypeid,
click.echo('No computer types found.') ipaddress=payload['ipaddress'], isprimary=True))
return
for app_data in payload.get('installedsoftware', []) or []:
click.echo('Computer Types:') name = app_data.get('name')
for t in types: if not name:
click.echo(f" [{t.computertypeid}] {t.computertype}") continue
app = Application.query.filter(Application.appname.ilike(name)).first()
@computerscli.command('stats') if not app:
def stats(): warnings.append(f'unknown application: {name}')
"""Show computer statistics.""" continue
from flask import current_app installed = ComputerInstalledApp.query.filter_by(
from shopdb.core.models import Asset computerid=comp.computerid, appid=app.appid).first()
version = app_data.get('version')
with current_app.app_context(): if installed:
total = db.session.query(Computer).join(Asset).filter( installed.installedversion = version
Asset.isactive == True installed.isactive = True
).count() else:
db.session.add(ComputerInstalledApp(
click.echo(f"Total active computers: {total}") computerid=comp.computerid, appid=app.appid,
installedversion=version))
# Shopfloor count
shopfloor = db.session.query(Computer).join(Asset).filter( db.session.commit()
Asset.isactive == True, return {
Computer.isshopfloor == True 'action': action,
).count() 'assetid': comp.assetid,
'identityvalue': hostname,
click.echo(f" Shopfloor PCs: {shopfloor}") 'warnings': warnings,
click.echo(f" Other: {total - shopfloor}") }
@computerscli.command('find') def on_install(self, app: Flask) -> None:
@click.argument('hostname') """Called when plugin is installed."""
def find_by_hostname(hostname): with app.app_context():
"""Find a computer by hostname.""" self._ensure_asset_type()
from flask import current_app self._ensure_computer_types()
logger.info("Computers plugin installed")
with current_app.app_context():
comp = Computer.query.filter( def _ensure_asset_type(self) -> None:
Computer.hostname.ilike(f'%{hostname}%') """Ensure computer asset type exists."""
).first() existing = AssetType.query.filter_by(assettype='computer').first()
if not existing:
if not comp: at = AssetType(
click.echo(f'No computer found matching hostname: {hostname}') assettype='computer',
return pluginname='computers',
tablename='computers',
click.echo(f'Found: {comp.hostname}') description='PCs, servers, and workstations',
click.echo(f' Asset: {comp.asset.assetnumber}') icon='desktop'
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"}') db.session.add(at)
click.echo(f' Logged in: {comp.loggedinuser or "N/A"}') logger.debug("Created asset type: computer")
db.session.commit()
return [computerscli]
def _ensure_computer_types(self) -> None:
def get_dashboard_widgets(self) -> List[Dict]: """Ensure basic computer types exist."""
"""Return dashboard widget definitions.""" computer_types = [
return [ ('Shopfloor PC', 'PC located on the shop floor for machine operation', 'desktop'),
{ ('Engineer Workstation', 'Engineering workstation for CAD/CAM work', 'laptop'),
'name': 'Computer Status', ('CMM PC', 'PC dedicated to CMM operation', 'desktop'),
'component': 'ComputerStatusWidget', ('Server', 'Server system', 'server'),
'endpoint': '/api/computers/dashboard/summary', ('Kiosk', 'Kiosk or info display PC', 'tv'),
'size': 'medium', ('Laptop', 'Laptop computer', 'laptop'),
'position': 6, ('Virtual Machine', 'Virtual machine', 'cloud'),
}, ('Other', 'Other computer type', 'desktop'),
] ]
def get_navigation_items(self) -> List[Dict]: for name, description, icon in computer_types:
"""Return navigation menu items.""" existing = ComputerType.query.filter_by(computertype=name).first()
return [ if not existing:
{ ct = ComputerType(
'name': 'PCs', computertype=name,
'icon': 'desktop', description=description,
'route': '/pcs', icon=icon
'position': 15, )
}, 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,
},
]

View File

@@ -3,15 +3,7 @@
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
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 ..models import Equipment, EquipmentType from ..models import Equipment, EquipmentType
@@ -160,19 +152,19 @@ def list_equipment():
) )
# Equipment type filter # 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)) query = query.filter(Equipment.equipmenttypeid == int(type_id))
# Vendor filter # 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)) query = query.filter(Equipment.vendorid == int(vendor_id))
# Location filter # 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)) query = query.filter(Asset.locationid == int(location_id))
# Business unit filter # 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)) query = query.filter(Asset.businessunitid == int(bu_id))
# Sorting # Sorting
@@ -282,6 +274,8 @@ def create_equipment():
asset = Asset( asset = Asset(
assetnumber=data['assetnumber'], assetnumber=data['assetnumber'],
name=data.get('name'), name=data.get('name'),
gaugelabreference=data.get('gaugelabreference'),
maintenancereference=data.get('maintenancereference'),
serialnumber=data.get('serialnumber'), serialnumber=data.get('serialnumber'),
assettypeid=equipment_type.assettypeid, assettypeid=equipment_type.assettypeid,
statusid=data.get('statusid', 1), statusid=data.get('statusid', 1),
@@ -357,8 +351,10 @@ def update_equipment(equipment_id: int):
changes = {} changes = {}
# Update asset fields # Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', asset_fields = ['assetnumber', 'name', 'gaugelabreference',
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] 'maintenancereference', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapx', 'mapy',
'notes', 'isactive']
for key in asset_fields: for key in asset_fields:
if key in data: if key in data:
old_val = getattr(asset, key) old_val = getattr(asset, key)
@@ -442,7 +438,7 @@ def dashboard_summary():
).all() ).all()
# Count by status # Count by status
from shopdb.core.models import AssetStatus from shopdb.api import AssetStatus
by_status = db.session.query( by_status = db.session.query(
AssetStatus.status, AssetStatus.status,
db.func.count(Equipment.equipmentid) db.func.count(Equipment.equipmentid)

View File

@@ -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

View File

@@ -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()

View File

@@ -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"}

View File

@@ -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')

View File

@@ -1,133 +1,132 @@
"""Equipment plugin models.""" """Equipment plugin models."""
from shopdb.extensions import db from shopdb.api import db, BaseModel
from shopdb.core.models.base import BaseModel
class EquipmentType(BaseModel):
class EquipmentType(BaseModel): """
""" Equipment type classification.
Equipment type classification.
Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc.
Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc. """
""" __tablename__ = 'equipmenttypes'
__tablename__ = 'equipmenttypes'
equipmenttypeid = db.Column(db.Integer, primary_key=True)
equipmenttypeid = db.Column(db.Integer, primary_key=True) equipmenttype = db.Column(db.String(100), unique=True, nullable=False)
equipmenttype = db.Column(db.String(100), unique=True, nullable=False) description = db.Column(db.Text)
description = db.Column(db.Text) icon = db.Column(db.String(50), comment='Icon name for UI')
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
def __repr__(self): return f"<EquipmentType {self.equipmenttype}>"
return f"<EquipmentType {self.equipmenttype}>"
class Equipment(BaseModel):
class Equipment(BaseModel): """
""" Equipment-specific extension data.
Equipment-specific extension data.
Links to core Asset table via assetid.
Links to core Asset table via assetid. Stores equipment-specific fields like type, model, vendor, etc.
Stores equipment-specific fields like type, model, vendor, etc. """
""" __tablename__ = 'equipment'
__tablename__ = 'equipment'
equipmentid = db.Column(db.Integer, primary_key=True)
equipmentid = db.Column(db.Integer, primary_key=True)
# Link to core asset
# Link to core asset assetid = db.Column(
assetid = db.Column( db.Integer,
db.Integer, db.ForeignKey('assets.assetid', ondelete='CASCADE'),
db.ForeignKey('assets.assetid', ondelete='CASCADE'), unique=True,
unique=True, nullable=False,
nullable=False, index=True
index=True )
)
# Equipment classification
# Equipment classification equipmenttypeid = db.Column(
equipmenttypeid = db.Column( db.Integer,
db.Integer, db.ForeignKey('equipmenttypes.equipmenttypeid'),
db.ForeignKey('equipmenttypes.equipmenttypeid'), nullable=True
nullable=True )
)
# Vendor and model
# Vendor and model vendorid = db.Column(
vendorid = db.Column( db.Integer,
db.Integer, db.ForeignKey('vendors.vendorid'),
db.ForeignKey('vendors.vendorid'), nullable=True
nullable=True )
) modelnumberid = db.Column(
modelnumberid = db.Column( db.Integer,
db.Integer, db.ForeignKey('models.modelnumberid'),
db.ForeignKey('models.modelnumberid'), nullable=True
nullable=True )
)
# Equipment-specific fields
# Equipment-specific fields requiresmanualconfig = db.Column(
requiresmanualconfig = db.Column( db.Boolean,
db.Boolean, default=False,
default=False, comment='Multi-PC machine needs manual configuration'
comment='Multi-PC machine needs manual configuration' )
) islocationonly = db.Column(
islocationonly = db.Column( db.Boolean,
db.Boolean, default=False,
default=False, comment='Virtual location marker (not actual equipment)'
comment='Virtual location marker (not actual equipment)' )
)
# Maintenance tracking
# Maintenance tracking lastmaintenancedate = db.Column(db.DateTime, nullable=True)
lastmaintenancedate = db.Column(db.DateTime, nullable=True) nextmaintenancedate = db.Column(db.DateTime, nullable=True)
nextmaintenancedate = db.Column(db.DateTime, nullable=True) maintenanceintervaldays = db.Column(db.Integer, nullable=True)
maintenanceintervaldays = db.Column(db.Integer, nullable=True)
# Controller info (for CNC machines)
# Controller info (for CNC machines) controllervendorid = db.Column(
controllervendorid = db.Column( db.Integer,
db.Integer, db.ForeignKey('vendors.vendorid'),
db.ForeignKey('vendors.vendorid'), nullable=True,
nullable=True, comment='Controller vendor (e.g., FANUC)'
comment='Controller vendor (e.g., FANUC)' )
) controllermodelid = db.Column(
controllermodelid = db.Column( db.Integer,
db.Integer, db.ForeignKey('models.modelnumberid'),
db.ForeignKey('models.modelnumberid'), nullable=True,
nullable=True, comment='Controller model (e.g., 31B)'
comment='Controller model (e.g., 31B)' )
)
# Relationships
# Relationships asset = db.relationship(
asset = db.relationship( 'Asset',
'Asset', backref=db.backref('equipment', uselist=False, lazy='joined')
backref=db.backref('equipment', uselist=False, lazy='joined') )
) equipmenttype = db.relationship('EquipmentType', backref='equipment')
equipmenttype = db.relationship('EquipmentType', backref='equipment') vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items')
vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items') model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items')
model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items') controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers')
controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers') controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models')
controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models')
__table_args__ = (
__table_args__ = ( db.Index('idx_equipment_type', 'equipmenttypeid'),
db.Index('idx_equipment_type', 'equipmenttypeid'), db.Index('idx_equipment_vendor', 'vendorid'),
db.Index('idx_equipment_vendor', 'vendorid'), )
)
def __repr__(self):
def __repr__(self): return f"<Equipment {self.assetid}>"
return f"<Equipment {self.assetid}>"
def to_dict(self):
def to_dict(self): """Convert to dictionary with related names."""
"""Convert to dictionary with related names.""" result = super().to_dict()
result = super().to_dict()
# Add related object names
# Add related object names if self.equipmenttype:
if self.equipmenttype: result['equipmenttypename'] = self.equipmenttype.equipmenttype
result['equipmenttypename'] = self.equipmenttype.equipmenttype if self.vendor:
if self.vendor: result['vendorname'] = self.vendor.vendor
result['vendorname'] = self.vendor.vendor if self.model:
if self.model: result['modelname'] = self.model.modelnumber
result['modelname'] = self.model.modelnumber if self.model.imageurl:
if self.model.imageurl: result['imageurl'] = self.model.imageurl
result['imageurl'] = self.model.imageurl
# Add controller info
# Add controller info if self.controllervendor:
if self.controllervendor: result['controllervendorname'] = self.controllervendor.vendor
result['controllervendorname'] = self.controllervendor.vendor if self.controllermodel:
if self.controllermodel: result['controllermodelname'] = self.controllermodel.modelnumber
result['controllermodelname'] = self.controllermodel.modelnumber
return result
return result

View File

@@ -1,220 +1,219 @@
"""Equipment plugin main class.""" """Equipment plugin main class."""
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional, Type from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint from flask import Flask, Blueprint
import click import click
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.api import db, AssetType, AssetStatus
from shopdb.core.models import AssetType, AssetStatus
from .models import Equipment, EquipmentType
from .models import Equipment, EquipmentType from .api import equipment_bp
from .api import equipment_bp
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
class EquipmentPlugin(BasePlugin):
class EquipmentPlugin(BasePlugin): """
""" Equipment plugin - manages manufacturing equipment assets.
Equipment plugin - manages manufacturing equipment assets.
Equipment includes CNCs, CMMs, lathes, grinders, EDMs, part markers, etc.
Equipment includes CNCs, CMMs, lathes, grinders, EDMs, part markers, etc. Uses the new Asset architecture with Equipment extension table.
Uses the new Asset architecture with Equipment extension table. """
"""
def __init__(self):
def __init__(self): self._manifest = self._load_manifest()
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
def _load_manifest(self) -> Dict: """Load plugin manifest from JSON file."""
"""Load plugin manifest from JSON file.""" manifestpath = Path(__file__).parent / 'manifest.json'
manifestpath = Path(__file__).parent / 'manifest.json' if manifestpath.exists():
if manifestpath.exists(): with open(manifestpath, 'r') as f:
with open(manifestpath, 'r') as f: return json.load(f)
return json.load(f) return {}
return {}
@property
@property def meta(self) -> PluginMeta:
def meta(self) -> PluginMeta: """Return plugin metadata."""
"""Return plugin metadata.""" return PluginMeta(
return PluginMeta( name=self._manifest.get('name', 'equipment'),
name=self._manifest.get('name', 'equipment'), version=self._manifest.get('version', '1.0.0'),
version=self._manifest.get('version', '1.0.0'), description=self._manifest.get(
description=self._manifest.get( 'description',
'description', 'Equipment management for manufacturing assets'
'Equipment management for manufacturing assets' ),
), author=self._manifest.get('author', 'ShopDB Team'),
author=self._manifest.get('author', 'ShopDB Team'), dependencies=self._manifest.get('dependencies', []),
dependencies=self._manifest.get('dependencies', []), core_version=self._manifest.get('core_version', '>=1.0.0'),
core_version=self._manifest.get('core_version', '>=1.0.0'), api_prefix=self._manifest.get('api_prefix', '/api/equipment'),
api_prefix=self._manifest.get('api_prefix', '/api/equipment'), )
)
def get_blueprint(self) -> Optional[Blueprint]:
def get_blueprint(self) -> Optional[Blueprint]: """Return Flask Blueprint with API routes."""
"""Return Flask Blueprint with API routes.""" return equipment_bp
return equipment_bp
def get_models(self) -> List[Type]:
def get_models(self) -> List[Type]: """Return list of SQLAlchemy model classes."""
"""Return list of SQLAlchemy model classes.""" return [Equipment, EquipmentType]
return [Equipment, EquipmentType]
def init_app(self, app: Flask, db_instance) -> None:
def init_app(self, app: Flask, db_instance) -> None: """Initialize plugin with Flask app."""
"""Initialize plugin with Flask app.""" logger.info(f"Equipment plugin initialized (v{self.meta.version})")
logger.info(f"Equipment plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
def on_install(self, app: Flask) -> None: """Called when plugin is installed."""
"""Called when plugin is installed.""" with app.app_context():
with app.app_context(): self._ensure_asset_type()
self._ensure_asset_type() self._ensure_asset_statuses()
self._ensure_asset_statuses() self._ensure_equipment_types()
self._ensure_equipment_types() logger.info("Equipment plugin installed")
logger.info("Equipment plugin installed")
def _ensure_asset_type(self) -> None:
def _ensure_asset_type(self) -> None: """Ensure equipment asset type exists."""
"""Ensure equipment asset type exists.""" existing = AssetType.query.filter_by(assettype='equipment').first()
existing = AssetType.query.filter_by(assettype='equipment').first() if not existing:
if not existing: at = AssetType(
at = AssetType( assettype='equipment',
assettype='equipment', pluginname='equipment',
pluginname='equipment', tablename='equipment',
tablename='equipment', description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)',
description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)', icon='cog'
icon='cog' )
) db.session.add(at)
db.session.add(at) logger.debug("Created asset type: equipment")
logger.debug("Created asset type: equipment") db.session.commit()
db.session.commit()
def _ensure_asset_statuses(self) -> None:
def _ensure_asset_statuses(self) -> None: """Ensure standard asset statuses exist."""
"""Ensure standard asset statuses exist.""" statuses = [
statuses = [ ('In Use', 'Asset is currently in use', '#28a745'),
('In Use', 'Asset is currently in use', '#28a745'), ('Spare', 'Spare/backup asset', '#17a2b8'),
('Spare', 'Spare/backup asset', '#17a2b8'), ('Retired', 'Asset has been retired', '#6c757d'),
('Retired', 'Asset has been retired', '#6c757d'), ('Maintenance', 'Asset is under maintenance', '#ffc107'),
('Maintenance', 'Asset is under maintenance', '#ffc107'), ('Decommissioned', 'Asset has been decommissioned', '#dc3545'),
('Decommissioned', 'Asset has been decommissioned', '#dc3545'), ]
]
for name, description, color in statuses:
for name, description, color in statuses: existing = AssetStatus.query.filter_by(status=name).first()
existing = AssetStatus.query.filter_by(status=name).first() if not existing:
if not existing: s = AssetStatus(
s = AssetStatus( status=name,
status=name, description=description,
description=description, color=color
color=color )
) db.session.add(s)
db.session.add(s) logger.debug(f"Created asset status: {name}")
logger.debug(f"Created asset status: {name}")
db.session.commit()
db.session.commit()
def _ensure_equipment_types(self) -> None:
def _ensure_equipment_types(self) -> None: """Ensure basic equipment types exist."""
"""Ensure basic equipment types exist.""" equipment_types = [
equipment_types = [ ('CNC', 'Computer Numerical Control machine', 'cnc'),
('CNC', 'Computer Numerical Control machine', 'cnc'), ('CMM', 'Coordinate Measuring Machine', 'cmm'),
('CMM', 'Coordinate Measuring Machine', 'cmm'), ('Lathe', 'Lathe machine', 'lathe'),
('Lathe', 'Lathe machine', 'lathe'), ('Grinder', 'Grinding machine', 'grinder'),
('Grinder', 'Grinding machine', 'grinder'), ('EDM', 'Electrical Discharge Machine', 'edm'),
('EDM', 'Electrical Discharge Machine', 'edm'), ('Part Marker', 'Part marking/engraving equipment', 'marker'),
('Part Marker', 'Part marking/engraving equipment', 'marker'), ('Mill', 'Milling machine', 'mill'),
('Mill', 'Milling machine', 'mill'), ('Press', 'Press machine', 'press'),
('Press', 'Press machine', 'press'), ('Robot', 'Industrial robot', 'robot'),
('Robot', 'Industrial robot', 'robot'), ('Other', 'Other equipment type', 'cog'),
('Other', 'Other equipment type', 'cog'), ]
]
for name, description, icon in equipment_types:
for name, description, icon in equipment_types: existing = EquipmentType.query.filter_by(equipmenttype=name).first()
existing = EquipmentType.query.filter_by(equipmenttype=name).first() if not existing:
if not existing: et = EquipmentType(
et = EquipmentType( equipmenttype=name,
equipmenttype=name, description=description,
description=description, icon=icon
icon=icon )
) db.session.add(et)
db.session.add(et) logger.debug(f"Created equipment type: {name}")
logger.debug(f"Created equipment type: {name}")
db.session.commit()
db.session.commit()
def on_uninstall(self, app: Flask) -> None:
def on_uninstall(self, app: Flask) -> None: """Called when plugin is uninstalled."""
"""Called when plugin is uninstalled.""" logger.info("Equipment plugin uninstalled")
logger.info("Equipment plugin uninstalled")
def get_cli_commands(self) -> List:
def get_cli_commands(self) -> List: """Return CLI commands for this plugin."""
"""Return CLI commands for this plugin."""
@click.group('equipment')
@click.group('equipment') def equipmentcli():
def equipmentcli(): """Equipment plugin commands."""
"""Equipment plugin commands.""" pass
pass
@equipmentcli.command('list-types')
@equipmentcli.command('list-types') def list_types():
def list_types(): """List all equipment types."""
"""List all equipment types.""" from flask import current_app
from flask import current_app
with current_app.app_context():
with current_app.app_context(): types = EquipmentType.query.filter_by(isactive=True).all()
types = EquipmentType.query.filter_by(isactive=True).all() if not types:
if not types: click.echo('No equipment types found.')
click.echo('No equipment types found.') return
return
click.echo('Equipment Types:')
click.echo('Equipment Types:') for t in types:
for t in types: click.echo(f" [{t.equipmenttypeid}] {t.equipmenttype}")
click.echo(f" [{t.equipmenttypeid}] {t.equipmenttype}")
@equipmentcli.command('stats')
@equipmentcli.command('stats') def stats():
def stats(): """Show equipment statistics."""
"""Show equipment statistics.""" from flask import current_app
from flask import current_app from shopdb.api import Asset
from shopdb.core.models import Asset
with current_app.app_context():
with current_app.app_context(): total = db.session.query(Equipment).join(Asset).filter(
total = db.session.query(Equipment).join(Asset).filter( Asset.isactive == True
Asset.isactive == True ).count()
).count()
click.echo(f"Total active equipment: {total}")
click.echo(f"Total active equipment: {total}")
# By type
# By type by_type = db.session.query(
by_type = db.session.query( EquipmentType.equipmenttype,
EquipmentType.equipmenttype, db.func.count(Equipment.equipmentid)
db.func.count(Equipment.equipmentid) ).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid ).join(Asset, Asset.assetid == Equipment.assetid
).join(Asset, Asset.assetid == Equipment.assetid ).filter(Asset.isactive == True
).filter(Asset.isactive == True ).group_by(EquipmentType.equipmenttype
).group_by(EquipmentType.equipmenttype ).all()
).all()
if by_type:
if by_type: click.echo("\nBy Type:")
click.echo("\nBy Type:") for t, c in by_type:
for t, c in by_type: click.echo(f" {t}: {c}")
click.echo(f" {t}: {c}")
return [equipmentcli]
return [equipmentcli]
def get_dashboard_widgets(self) -> List[Dict]:
def get_dashboard_widgets(self) -> List[Dict]: """Return dashboard widget definitions."""
"""Return dashboard widget definitions.""" return [
return [ {
{ 'name': 'Equipment Status',
'name': 'Equipment Status', 'component': 'EquipmentStatusWidget',
'component': 'EquipmentStatusWidget', 'endpoint': '/api/equipment/dashboard/summary',
'endpoint': '/api/equipment/dashboard/summary', 'size': 'medium',
'size': 'medium', 'position': 5,
'position': 5, },
}, ]
]
def get_navigation_items(self) -> List[Dict]:
def get_navigation_items(self) -> List[Dict]: """Return navigation menu items."""
"""Return navigation menu items.""" return [
return [ {
{ 'name': 'Equipment',
'name': 'Equipment', 'icon': 'cog',
'icon': 'cog', 'route': '/machines',
'route': '/machines', 'position': 10,
'position': 10, },
}, ]
]

View File

@@ -3,15 +3,7 @@
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.api import db, Asset, AssetType, Vendor, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
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 ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
@@ -163,19 +155,19 @@ def list_network_devices():
) )
# Type filter # 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)) query = query.filter(NetworkDevice.networkdevicetypeid == int(type_id))
# Vendor filter # 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)) query = query.filter(NetworkDevice.vendorid == int(vendor_id))
# Location filter # 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)) query = query.filter(Asset.locationid == int(location_id))
# Business unit filter # 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)) query = query.filter(Asset.businessunitid == int(bu_id))
# PoE filter # PoE filter
@@ -207,7 +199,7 @@ def list_network_devices():
data = [] data = []
for netdev in items: for netdev in items:
item = netdev.asset.to_dict() if netdev.asset else {} item = netdev.asset.to_dict() if netdev.asset else {}
item['network_device'] = netdev.to_dict() item['networkdevice'] = netdev.to_dict()
data.append(item) data.append(item)
return paginated_response(data, page, per_page, total) return paginated_response(data, page, per_page, total)
@@ -324,6 +316,8 @@ def create_network_device():
assetnumber=data['assetnumber'], assetnumber=data['assetnumber'],
name=data.get('name'), name=data.get('name'),
serialnumber=data.get('serialnumber'), serialnumber=data.get('serialnumber'),
gaugelabreference=data.get('gaugelabreference'),
maintenancereference=data.get('maintenancereference'),
assettypeid=network_type.assettypeid, assettypeid=network_type.assettypeid,
statusid=data.get('statusid', 1), statusid=data.get('statusid', 1),
locationid=data.get('locationid'), locationid=data.get('locationid'),
@@ -406,7 +400,8 @@ def update_network_device(device_id: int):
changes = {} changes = {}
# Update asset fields # Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
'maintenancereference', 'statusid',
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
for key in asset_fields: for key in asset_fields:
if key in data: if key in data:

View File

@@ -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

View File

@@ -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()

View File

@@ -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"}

View File

@@ -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')

View File

@@ -1,121 +1,120 @@
"""Network device plugin models.""" """Network device plugin models."""
from shopdb.extensions import db from shopdb.api import db, BaseModel
from shopdb.core.models.base import BaseModel
class NetworkDeviceType(BaseModel):
class NetworkDeviceType(BaseModel): """
""" Network device type classification.
Network device type classification.
Examples: Switch, Router, Access Point, Camera, IDF, Firewall, etc.
Examples: Switch, Router, Access Point, Camera, IDF, Firewall, etc. """
""" __tablename__ = 'networkdevicetypes'
__tablename__ = 'networkdevicetypes'
networkdevicetypeid = db.Column(db.Integer, primary_key=True)
networkdevicetypeid = db.Column(db.Integer, primary_key=True) networkdevicetype = db.Column(db.String(100), unique=True, nullable=False)
networkdevicetype = db.Column(db.String(100), unique=True, nullable=False) description = db.Column(db.Text)
description = db.Column(db.Text) icon = db.Column(db.String(50), comment='Icon name for UI')
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
def __repr__(self): return f"<NetworkDeviceType {self.networkdevicetype}>"
return f"<NetworkDeviceType {self.networkdevicetype}>"
class NetworkDevice(BaseModel):
class NetworkDevice(BaseModel): """
""" Network device-specific extension data.
Network device-specific extension data.
Links to core Asset table via assetid.
Links to core Asset table via assetid. Stores network device-specific fields like hostname, firmware, ports, etc.
Stores network device-specific fields like hostname, firmware, ports, etc. """
""" __tablename__ = 'networkdevices'
__tablename__ = 'networkdevices'
networkdeviceid = db.Column(db.Integer, primary_key=True)
networkdeviceid = db.Column(db.Integer, primary_key=True)
# Link to core asset
# Link to core asset assetid = db.Column(
assetid = db.Column( db.Integer,
db.Integer, db.ForeignKey('assets.assetid', ondelete='CASCADE'),
db.ForeignKey('assets.assetid', ondelete='CASCADE'), unique=True,
unique=True, nullable=False,
nullable=False, index=True
index=True )
)
# Network device classification
# Network device classification networkdevicetypeid = db.Column(
networkdevicetypeid = db.Column( db.Integer,
db.Integer, db.ForeignKey('networkdevicetypes.networkdevicetypeid'),
db.ForeignKey('networkdevicetypes.networkdevicetypeid'), nullable=True
nullable=True )
)
# Vendor
# Vendor vendorid = db.Column(
vendorid = db.Column( db.Integer,
db.Integer, db.ForeignKey('vendors.vendorid'),
db.ForeignKey('vendors.vendorid'), nullable=True
nullable=True )
)
# Network identity
# Network identity hostname = db.Column(
hostname = db.Column( db.String(100),
db.String(100), index=True,
index=True, comment='Network hostname'
comment='Network hostname' )
)
# Firmware/software version
# Firmware/software version firmwareversion = db.Column(db.String(100), nullable=True)
firmwareversion = db.Column(db.String(100), nullable=True)
# Physical characteristics
# Physical characteristics portcount = db.Column(
portcount = db.Column( db.Integer,
db.Integer, nullable=True,
nullable=True, comment='Number of ports (for switches)'
comment='Number of ports (for switches)' )
)
# Features
# Features ispoe = db.Column(
ispoe = db.Column( db.Boolean,
db.Boolean, default=False,
default=False, comment='Power over Ethernet capable'
comment='Power over Ethernet capable' )
) ismanaged = db.Column(
ismanaged = db.Column( db.Boolean,
db.Boolean, default=False,
default=False, comment='Managed device (SNMP, web interface, etc.)'
comment='Managed device (SNMP, web interface, etc.)' )
)
# For IDF/closet locations
# For IDF/closet locations rackunit = db.Column(
rackunit = db.Column( db.String(20),
db.String(20), nullable=True,
nullable=True, comment='Rack unit position (e.g., U1, U5)'
comment='Rack unit position (e.g., U1, U5)' )
)
# Relationships
# Relationships asset = db.relationship(
asset = db.relationship( 'Asset',
'Asset', backref=db.backref('network_device', uselist=False, lazy='joined')
backref=db.backref('network_device', uselist=False, lazy='joined') )
) networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices')
networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices') vendor = db.relationship('Vendor', backref='network_devices')
vendor = db.relationship('Vendor', backref='network_devices')
__table_args__ = (
__table_args__ = ( db.Index('idx_netdev_type', 'networkdevicetypeid'),
db.Index('idx_netdev_type', 'networkdevicetypeid'), db.Index('idx_netdev_hostname', 'hostname'),
db.Index('idx_netdev_hostname', 'hostname'), db.Index('idx_netdev_vendor', 'vendorid'),
db.Index('idx_netdev_vendor', 'vendorid'), )
)
def __repr__(self):
def __repr__(self): return f"<NetworkDevice {self.hostname or self.assetid}>"
return f"<NetworkDevice {self.hostname or self.assetid}>"
def to_dict(self):
def to_dict(self): """Convert to dictionary with related names."""
"""Convert to dictionary with related names.""" result = super().to_dict()
result = super().to_dict()
# Add related object names
# Add related object names if self.networkdevicetype:
if self.networkdevicetype: result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype
result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype if self.vendor:
if self.vendor: result['vendorname'] = self.vendor.vendor
result['vendorname'] = self.vendor.vendor
return result
return result

View File

@@ -1,146 +1,145 @@
"""Subnet and VLAN models for network plugin.""" """Subnet and VLAN models for network plugin."""
from shopdb.extensions import db from shopdb.api import db, BaseModel
from shopdb.core.models.base import BaseModel
class VLAN(BaseModel):
class VLAN(BaseModel): """
""" VLAN definition.
VLAN definition.
Represents a virtual LAN for network segmentation.
Represents a virtual LAN for network segmentation. """
""" __tablename__ = 'vlans'
__tablename__ = 'vlans'
vlanid = db.Column(db.Integer, primary_key=True)
vlanid = db.Column(db.Integer, primary_key=True) vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number')
vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number') name = db.Column(db.String(100), nullable=False, comment='VLAN name')
name = db.Column(db.String(100), nullable=False, comment='VLAN name') description = db.Column(db.Text, nullable=True)
description = db.Column(db.Text, nullable=True)
# Optional classification
# Optional classification vlantype = db.Column(
vlantype = db.Column( db.String(50),
db.String(50), nullable=True,
nullable=True, comment='Type: data, voice, management, guest, etc.'
comment='Type: data, voice, management, guest, etc.' )
)
# Relationships
# Relationships subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic')
subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic')
__table_args__ = (
__table_args__ = ( db.Index('idx_vlan_number', 'vlannumber'),
db.Index('idx_vlan_number', 'vlannumber'), )
)
def __repr__(self):
def __repr__(self): return f"<VLAN {self.vlannumber} - {self.name}>"
return f"<VLAN {self.vlannumber} - {self.name}>"
def to_dict(self):
def to_dict(self): """Convert to dictionary."""
"""Convert to dictionary.""" result = super().to_dict()
result = super().to_dict() result['subnetcount'] = self.subnets.count() if self.subnets else 0
result['subnetcount'] = self.subnets.count() if self.subnets else 0 return result
return result
class Subnet(BaseModel):
class Subnet(BaseModel): """
""" Subnet/IP network definition.
Subnet/IP network definition.
Represents an IP subnet with optional VLAN association.
Represents an IP subnet with optional VLAN association. """
""" __tablename__ = 'subnets'
__tablename__ = 'subnets'
subnetid = db.Column(db.Integer, primary_key=True)
subnetid = db.Column(db.Integer, primary_key=True)
# Network definition
# Network definition cidr = db.Column(
cidr = db.Column( db.String(18),
db.String(18), unique=True,
unique=True, nullable=False,
nullable=False, comment='CIDR notation (e.g., 10.1.1.0/24)'
comment='CIDR notation (e.g., 10.1.1.0/24)' )
) name = db.Column(db.String(100), nullable=False, comment='Subnet name')
name = db.Column(db.String(100), nullable=False, comment='Subnet name') description = db.Column(db.Text, nullable=True)
description = db.Column(db.Text, nullable=True)
# Network details
# Network details gatewayip = db.Column(
gatewayip = db.Column( db.String(15),
db.String(15), nullable=True,
nullable=True, comment='Default gateway IP address'
comment='Default gateway IP address' )
) subnetmask = db.Column(
subnetmask = db.Column( db.String(15),
db.String(15), nullable=True,
nullable=True, comment='Subnet mask (e.g., 255.255.255.0)'
comment='Subnet mask (e.g., 255.255.255.0)' )
) networkaddress = db.Column(
networkaddress = db.Column( db.String(15),
db.String(15), nullable=True,
nullable=True, comment='Network address (e.g., 10.1.1.0)'
comment='Network address (e.g., 10.1.1.0)' )
) broadcastaddress = db.Column(
broadcastaddress = db.Column( db.String(15),
db.String(15), nullable=True,
nullable=True, comment='Broadcast address (e.g., 10.1.1.255)'
comment='Broadcast address (e.g., 10.1.1.255)' )
)
# VLAN association
# VLAN association vlanid = db.Column(
vlanid = db.Column( db.Integer,
db.Integer, db.ForeignKey('vlans.vlanid'),
db.ForeignKey('vlans.vlanid'), nullable=True
nullable=True )
)
# Classification
# Classification subnettype = db.Column(
subnettype = db.Column( db.String(50),
db.String(50), nullable=True,
nullable=True, comment='Type: production, development, management, dmz, etc.'
comment='Type: production, development, management, dmz, etc.' )
)
# Location association
# Location association locationid = db.Column(
locationid = db.Column( db.Integer,
db.Integer, db.ForeignKey('locations.locationid'),
db.ForeignKey('locations.locationid'), nullable=True
nullable=True )
)
# DHCP settings
# DHCP settings dhcpenabled = db.Column(db.Boolean, default=True, comment='DHCP enabled for this subnet')
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')
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')
dhcprangeend = db.Column(db.String(15), nullable=True, comment='DHCP range end IP')
# DNS settings
# DNS settings dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server')
dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server') dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server')
dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server')
# Relationships
# Relationships location = db.relationship('Location', backref='subnets')
location = db.relationship('Location', backref='subnets')
__table_args__ = (
__table_args__ = ( db.Index('idx_subnet_cidr', 'cidr'),
db.Index('idx_subnet_cidr', 'cidr'), db.Index('idx_subnet_vlan', 'vlanid'),
db.Index('idx_subnet_vlan', 'vlanid'), db.Index('idx_subnet_location', 'locationid'),
db.Index('idx_subnet_location', 'locationid'), )
)
def __repr__(self):
def __repr__(self): return f"<Subnet {self.cidr} - {self.name}>"
return f"<Subnet {self.cidr} - {self.name}>"
@property
@property def vlan_number(self):
def vlan_number(self): """Get the VLAN number."""
"""Get the VLAN number.""" return self.vlan.vlannumber if self.vlan else None
return self.vlan.vlannumber if self.vlan else None
def to_dict(self):
def to_dict(self): """Convert to dictionary with related data."""
"""Convert to dictionary with related data.""" result = super().to_dict()
result = super().to_dict()
# Add VLAN info
# Add VLAN info if self.vlan:
if self.vlan: result['vlannumber'] = self.vlan.vlannumber
result['vlannumber'] = self.vlan.vlannumber result['vlanname'] = self.vlan.name
result['vlanname'] = self.vlan.name
# Add location info
# Add location info if self.location:
if self.location: result['locationname'] = self.location.locationname
result['locationname'] = self.location.locationname
return result
return result

View File

@@ -1,217 +1,216 @@
"""Network plugin main class.""" """Network plugin main class."""
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional, Type from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint from flask import Flask, Blueprint
import click import click
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.api import db, AssetType
from shopdb.core.models import AssetType
from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN from .api import network_bp
from .api import network_bp
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
class NetworkPlugin(BasePlugin):
class NetworkPlugin(BasePlugin): """
""" Network plugin - manages network device assets.
Network plugin - manages network device assets.
Network devices include switches, routers, access points, cameras, IDFs, etc.
Network devices include switches, routers, access points, cameras, IDFs, etc. Uses the new Asset architecture with NetworkDevice extension table.
Uses the new Asset architecture with NetworkDevice extension table. """
"""
def __init__(self):
def __init__(self): self._manifest = self._load_manifest()
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
def _load_manifest(self) -> Dict: """Load plugin manifest from JSON file."""
"""Load plugin manifest from JSON file.""" manifestpath = Path(__file__).parent / 'manifest.json'
manifestpath = Path(__file__).parent / 'manifest.json' if manifestpath.exists():
if manifestpath.exists(): with open(manifestpath, 'r') as f:
with open(manifestpath, 'r') as f: return json.load(f)
return json.load(f) return {}
return {}
@property
@property def meta(self) -> PluginMeta:
def meta(self) -> PluginMeta: """Return plugin metadata."""
"""Return plugin metadata.""" return PluginMeta(
return PluginMeta( name=self._manifest.get('name', 'network'),
name=self._manifest.get('name', 'network'), version=self._manifest.get('version', '1.0.0'),
version=self._manifest.get('version', '1.0.0'), description=self._manifest.get(
description=self._manifest.get( 'description',
'description', 'Network device management for switches, APs, and cameras'
'Network device management for switches, APs, and cameras' ),
), author=self._manifest.get('author', 'ShopDB Team'),
author=self._manifest.get('author', 'ShopDB Team'), dependencies=self._manifest.get('dependencies', []),
dependencies=self._manifest.get('dependencies', []), core_version=self._manifest.get('core_version', '>=1.0.0'),
core_version=self._manifest.get('core_version', '>=1.0.0'), api_prefix=self._manifest.get('api_prefix', '/api/network'),
api_prefix=self._manifest.get('api_prefix', '/api/network'), )
)
def get_blueprint(self) -> Optional[Blueprint]:
def get_blueprint(self) -> Optional[Blueprint]: """Return Flask Blueprint with API routes."""
"""Return Flask Blueprint with API routes.""" return network_bp
return network_bp
def get_models(self) -> List[Type]:
def get_models(self) -> List[Type]: """Return list of SQLAlchemy model classes."""
"""Return list of SQLAlchemy model classes.""" return [NetworkDevice, NetworkDeviceType, Subnet, VLAN]
return [NetworkDevice, NetworkDeviceType, Subnet, VLAN]
def init_app(self, app: Flask, db_instance) -> None:
def init_app(self, app: Flask, db_instance) -> None: """Initialize plugin with Flask app."""
"""Initialize plugin with Flask app.""" logger.info(f"Network plugin initialized (v{self.meta.version})")
logger.info(f"Network plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
def on_install(self, app: Flask) -> None: """Called when plugin is installed."""
"""Called when plugin is installed.""" with app.app_context():
with app.app_context(): self._ensure_asset_type()
self._ensure_asset_type() self._ensure_network_device_types()
self._ensure_network_device_types() logger.info("Network plugin installed")
logger.info("Network plugin installed")
def _ensure_asset_type(self) -> None:
def _ensure_asset_type(self) -> None: """Ensure network_device asset type exists."""
"""Ensure network_device asset type exists.""" existing = AssetType.query.filter_by(assettype='network_device').first()
existing = AssetType.query.filter_by(assettype='network_device').first() if not existing:
if not existing: at = AssetType(
at = AssetType( assettype='network_device',
assettype='network_device', pluginname='network',
pluginname='network', tablename='networkdevices',
tablename='networkdevices', description='Network infrastructure devices (switches, APs, cameras, etc.)',
description='Network infrastructure devices (switches, APs, cameras, etc.)', icon='network-wired'
icon='network-wired' )
) db.session.add(at)
db.session.add(at) logger.debug("Created asset type: network_device")
logger.debug("Created asset type: network_device") db.session.commit()
db.session.commit()
def _ensure_network_device_types(self) -> None:
def _ensure_network_device_types(self) -> None: """Ensure basic network device types exist."""
"""Ensure basic network device types exist.""" device_types = [
device_types = [ ('Switch', 'Network switch', 'network-wired'),
('Switch', 'Network switch', 'network-wired'), ('Router', 'Network router', 'router'),
('Router', 'Network router', 'router'), ('Access Point', 'Wireless access point', 'wifi'),
('Access Point', 'Wireless access point', 'wifi'), ('Firewall', 'Network firewall', 'shield'),
('Firewall', 'Network firewall', 'shield'), ('Camera', 'IP camera', 'video'),
('Camera', 'IP camera', 'video'), ('IDF', 'Intermediate Distribution Frame/closet', 'box'),
('IDF', 'Intermediate Distribution Frame/closet', 'box'), ('MDF', 'Main Distribution Frame', 'building'),
('MDF', 'Main Distribution Frame', 'building'), ('Patch Panel', 'Patch panel', 'th'),
('Patch Panel', 'Patch panel', 'th'), ('UPS', 'Uninterruptible power supply', 'battery'),
('UPS', 'Uninterruptible power supply', 'battery'), ('Other', 'Other network device', 'network-wired'),
('Other', 'Other network device', 'network-wired'), ]
]
for name, description, icon in device_types:
for name, description, icon in device_types: existing = NetworkDeviceType.query.filter_by(networkdevicetype=name).first()
existing = NetworkDeviceType.query.filter_by(networkdevicetype=name).first() if not existing:
if not existing: ndt = NetworkDeviceType(
ndt = NetworkDeviceType( networkdevicetype=name,
networkdevicetype=name, description=description,
description=description, icon=icon
icon=icon )
) db.session.add(ndt)
db.session.add(ndt) logger.debug(f"Created network device type: {name}")
logger.debug(f"Created network device type: {name}")
db.session.commit()
db.session.commit()
def on_uninstall(self, app: Flask) -> None:
def on_uninstall(self, app: Flask) -> None: """Called when plugin is uninstalled."""
"""Called when plugin is uninstalled.""" logger.info("Network plugin uninstalled")
logger.info("Network plugin uninstalled")
def get_cli_commands(self) -> List:
def get_cli_commands(self) -> List: """Return CLI commands for this plugin."""
"""Return CLI commands for this plugin."""
@click.group('network')
@click.group('network') def networkcli():
def networkcli(): """Network plugin commands."""
"""Network plugin commands.""" pass
pass
@networkcli.command('list-types')
@networkcli.command('list-types') def list_types():
def list_types(): """List all network device types."""
"""List all network device types.""" from flask import current_app
from flask import current_app
with current_app.app_context():
with current_app.app_context(): types = NetworkDeviceType.query.filter_by(isactive=True).all()
types = NetworkDeviceType.query.filter_by(isactive=True).all() if not types:
if not types: click.echo('No network device types found.')
click.echo('No network device types found.') return
return
click.echo('Network Device Types:')
click.echo('Network Device Types:') for t in types:
for t in types: click.echo(f" [{t.networkdevicetypeid}] {t.networkdevicetype}")
click.echo(f" [{t.networkdevicetypeid}] {t.networkdevicetype}")
@networkcli.command('stats')
@networkcli.command('stats') def stats():
def stats(): """Show network device statistics."""
"""Show network device statistics.""" from flask import current_app
from flask import current_app from shopdb.api import Asset
from shopdb.core.models import Asset
with current_app.app_context():
with current_app.app_context(): total = db.session.query(NetworkDevice).join(Asset).filter(
total = db.session.query(NetworkDevice).join(Asset).filter( Asset.isactive == True
Asset.isactive == True ).count()
).count()
click.echo(f"Total active network devices: {total}")
click.echo(f"Total active network devices: {total}")
# By type
# By type by_type = db.session.query(
by_type = db.session.query( NetworkDeviceType.networkdevicetype,
NetworkDeviceType.networkdevicetype, db.func.count(NetworkDevice.networkdeviceid)
db.func.count(NetworkDevice.networkdeviceid) ).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid
).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid ).join(Asset, Asset.assetid == NetworkDevice.assetid
).join(Asset, Asset.assetid == NetworkDevice.assetid ).filter(Asset.isactive == True
).filter(Asset.isactive == True ).group_by(NetworkDeviceType.networkdevicetype
).group_by(NetworkDeviceType.networkdevicetype ).all()
).all()
if by_type:
if by_type: click.echo("\nBy Type:")
click.echo("\nBy Type:") for t, c in by_type:
for t, c in by_type: click.echo(f" {t}: {c}")
click.echo(f" {t}: {c}")
@networkcli.command('find')
@networkcli.command('find') @click.argument('hostname')
@click.argument('hostname') def find_by_hostname(hostname):
def find_by_hostname(hostname): """Find a network device by hostname."""
"""Find a network device by hostname.""" from flask import current_app
from flask import current_app
with current_app.app_context():
with current_app.app_context(): netdev = NetworkDevice.query.filter(
netdev = NetworkDevice.query.filter( NetworkDevice.hostname.ilike(f'%{hostname}%')
NetworkDevice.hostname.ilike(f'%{hostname}%') ).first()
).first()
if not netdev:
if not netdev: click.echo(f'No network device found matching hostname: {hostname}')
click.echo(f'No network device found matching hostname: {hostname}') return
return
click.echo(f'Found: {netdev.hostname}')
click.echo(f'Found: {netdev.hostname}') click.echo(f' Asset: {netdev.asset.assetnumber}')
click.echo(f' Asset: {netdev.asset.assetnumber}') click.echo(f' Type: {netdev.networkdevicetype.networkdevicetype if netdev.networkdevicetype else "N/A"}')
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' Firmware: {netdev.firmwareversion or "N/A"}') click.echo(f' PoE: {"Yes" if netdev.ispoe else "No"}')
click.echo(f' PoE: {"Yes" if netdev.ispoe else "No"}')
return [networkcli]
return [networkcli]
def get_dashboard_widgets(self) -> List[Dict]:
def get_dashboard_widgets(self) -> List[Dict]: """Return dashboard widget definitions."""
"""Return dashboard widget definitions.""" return [
return [ {
{ 'name': 'Network Status',
'name': 'Network Status', 'component': 'NetworkStatusWidget',
'component': 'NetworkStatusWidget', 'endpoint': '/api/network/dashboard/summary',
'endpoint': '/api/network/dashboard/summary', 'size': 'medium',
'size': 'medium', 'position': 7,
'position': 7, },
}, ]
]
def get_navigation_items(self) -> List[Dict]:
def get_navigation_items(self) -> List[Dict]: """Return navigation menu items."""
"""Return navigation menu items.""" return [
return [ {
{ 'name': 'Network',
'name': 'Network', 'icon': 'network-wired',
'icon': 'network-wired', 'route': '/network',
'route': '/network', 'position': 18,
'position': 18, },
}, ]
]

View File

@@ -4,14 +4,7 @@ from datetime import datetime
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, employee_connection
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 Notification, NotificationType from ..models import Notification, NotificationType
@@ -93,7 +86,7 @@ def list_notifications():
query = query.filter(Notification.isactive == True) query = query.filter(Notification.isactive == True)
# Type filter # 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)) query = query.filter(Notification.notificationtypeid == int(type_id))
# Current filter (active based on dates) # Current filter (active based on dates)
@@ -522,11 +515,7 @@ def get_shopfloor_notifications():
# Try to get picture from wjf_employees # Try to get picture from wjf_employees
if n.employeesso and n.employeesso.isdigit(): if n.employeesso and n.employeesso.isdigit():
try: try:
import pymysql conn = employee_connection()
conn = pymysql.connect(
host='localhost', user='root', password='rootpassword',
database='wjf_employees', cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(n.employeesso),)) cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(n.employeesso),))
emp = cur.fetchone() emp = cur.fetchone()
@@ -555,11 +544,7 @@ def get_shopfloor_notifications():
picture = None picture = None
if sso.isdigit(): if sso.isdigit():
try: try:
import pymysql conn = employee_connection()
conn = pymysql.connect(
host='localhost', user='root', password='rootpassword',
database='wjf_employees', cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),)) cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
emp = cur.fetchone() emp = cur.fetchone()
@@ -591,11 +576,7 @@ def get_shopfloor_notifications():
picture = None picture = None
if sso.isdigit(): if sso.isdigit():
try: try:
import pymysql conn = employee_connection()
conn = pymysql.connect(
host='localhost', user='root', password='rootpassword',
database='wjf_employees', cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),)) cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
emp = cur.fetchone() emp = cur.fetchone()

View File

@@ -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

View File

@@ -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()

View File

@@ -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"}

View File

@@ -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')

View File

@@ -1,157 +1,157 @@
"""Notifications plugin models - adapted to existing database schema.""" """Notifications plugin models - adapted to existing database schema."""
from datetime import datetime from datetime import datetime
from shopdb.extensions import db from shopdb.api import db
class NotificationType(db.Model): class NotificationType(db.Model):
""" """
Notification type classification. Notification type classification.
Matches existing notificationtypes table. Matches existing notificationtypes table.
""" """
__tablename__ = 'notificationtypes' __tablename__ = 'notificationtypes'
notificationtypeid = db.Column(db.Integer, primary_key=True) notificationtypeid = db.Column(db.Integer, primary_key=True)
typename = db.Column(db.String(50), nullable=False) typename = db.Column(db.String(50), nullable=False)
typedescription = db.Column(db.Text) typedescription = db.Column(db.Text)
typecolor = db.Column(db.String(20), default='#17a2b8') typecolor = db.Column(db.String(20), default='#17a2b8')
isactive = db.Column(db.Boolean, default=True) isactive = db.Column(db.Boolean, default=True)
def __repr__(self): def __repr__(self):
return f"<NotificationType {self.typename}>" return f"<NotificationType {self.typename}>"
def to_dict(self): def to_dict(self):
return { return {
'notificationtypeid': self.notificationtypeid, 'notificationtypeid': self.notificationtypeid,
'typename': self.typename, 'typename': self.typename,
'typedescription': self.typedescription, 'typedescription': self.typedescription,
'typecolor': self.typecolor, 'typecolor': self.typecolor,
'isactive': self.isactive 'isactive': self.isactive
} }
class Notification(db.Model): class Notification(db.Model):
""" """
Notification/announcement model. Notification/announcement model.
Matches existing notifications table schema. Matches existing notifications table schema.
""" """
__tablename__ = 'notifications' __tablename__ = 'notifications'
notificationid = db.Column(db.Integer, primary_key=True) notificationid = db.Column(db.Integer, primary_key=True)
notificationtypeid = db.Column( notificationtypeid = db.Column(
db.Integer, db.Integer,
db.ForeignKey('notificationtypes.notificationtypeid'), db.ForeignKey('notificationtypes.notificationtypeid'),
nullable=True nullable=True
) )
businessunitid = db.Column(db.Integer, nullable=True) businessunitid = db.Column(db.Integer, nullable=True)
appid = db.Column(db.Integer, nullable=True) appid = db.Column(db.Integer, nullable=True)
notification = db.Column(db.Text, nullable=False, comment='The message content') notification = db.Column(db.Text, nullable=False, comment='The message content')
starttime = db.Column(db.DateTime, nullable=True) starttime = db.Column(db.DateTime, nullable=True)
endtime = db.Column(db.DateTime, nullable=True) endtime = db.Column(db.DateTime, nullable=True)
ticketnumber = db.Column(db.String(50), nullable=True) ticketnumber = db.Column(db.String(50), nullable=True)
link = db.Column(db.String(500), nullable=True) link = db.Column(db.String(500), nullable=True)
isactive = db.Column(db.Boolean, default=True) isactive = db.Column(db.Boolean, default=True)
isshopfloor = db.Column(db.Boolean, default=False) isshopfloor = db.Column(db.Boolean, default=False)
employeesso = db.Column(db.String(100), nullable=True) employeesso = db.Column(db.String(100), nullable=True)
employeename = db.Column(db.String(100), nullable=True) employeename = db.Column(db.String(100), nullable=True)
# Relationships # Relationships
notificationtype = db.relationship('NotificationType', backref='notifications') notificationtype = db.relationship('NotificationType', backref='notifications')
def __repr__(self): def __repr__(self):
return f"<Notification {self.notificationid}>" return f"<Notification {self.notificationid}>"
@property @property
def is_current(self): def is_current(self):
"""Check if notification is currently active based on dates.""" """Check if notification is currently active based on dates."""
now = datetime.utcnow() now = datetime.utcnow()
if not self.isactive: if not self.isactive:
return False return False
if self.starttime and now < self.starttime: if self.starttime and now < self.starttime:
return False return False
if self.endtime and now > self.endtime: if self.endtime and now > self.endtime:
return False return False
return True return True
@property @property
def title(self): def title(self):
"""Get title - first line or first 100 chars of notification.""" """Get title - first line or first 100 chars of notification."""
if not self.notification: if not self.notification:
return '' return ''
lines = self.notification.split('\n') lines = self.notification.split('\n')
return lines[0][:100] if lines else self.notification[:100] return lines[0][:100] if lines else self.notification[:100]
def to_dict(self): def to_dict(self):
"""Convert to dictionary with related data.""" """Convert to dictionary with related data."""
result = { result = {
'notificationid': self.notificationid, 'notificationid': self.notificationid,
'notificationtypeid': self.notificationtypeid, 'notificationtypeid': self.notificationtypeid,
'businessunitid': self.businessunitid, 'businessunitid': self.businessunitid,
'appid': self.appid, 'appid': self.appid,
'notification': self.notification, 'notification': self.notification,
'title': self.title, 'title': self.title,
'message': self.notification, 'message': self.notification,
'starttime': self.starttime.isoformat() if self.starttime else None, 'starttime': self.starttime.isoformat() if self.starttime else None,
'endtime': self.endtime.isoformat() if self.endtime else None, 'endtime': self.endtime.isoformat() if self.endtime else None,
'startdate': self.starttime.isoformat() if self.starttime else None, 'startdate': self.starttime.isoformat() if self.starttime else None,
'enddate': self.endtime.isoformat() if self.endtime else None, 'enddate': self.endtime.isoformat() if self.endtime else None,
'ticketnumber': self.ticketnumber, 'ticketnumber': self.ticketnumber,
'link': self.link, 'link': self.link,
'linkurl': self.link, 'linkurl': self.link,
'isactive': bool(self.isactive) if self.isactive is not None else True, 'isactive': bool(self.isactive) if self.isactive is not None else True,
'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False, 'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False,
'employeesso': self.employeesso, 'employeesso': self.employeesso,
'employeename': self.employeename, 'employeename': self.employeename,
'iscurrent': self.is_current 'iscurrent': self.is_current
} }
# Add type info # Add type info
if self.notificationtype: if self.notificationtype:
result['typename'] = self.notificationtype.typename result['typename'] = self.notificationtype.typename
result['typecolor'] = self.notificationtype.typecolor result['typecolor'] = self.notificationtype.typecolor
return result return result
def to_calendar_event(self): def to_calendar_event(self):
"""Convert to FullCalendar event format.""" """Convert to FullCalendar event format."""
# Map Bootstrap color names to hex colors # Map Bootstrap color names to hex colors
color_map = { color_map = {
'success': '#04b962', 'success': '#04b962',
'warning': '#ff8800', 'warning': '#ff8800',
'danger': '#f5365c', 'danger': '#f5365c',
'info': '#14abef', 'info': '#14abef',
'primary': '#7934f3', 'primary': '#7934f3',
'secondary': '#94614f', 'secondary': '#94614f',
'recognition': '#14abef', # Blue for recognition 'recognition': '#14abef', # Blue for recognition
} }
raw_color = self.notificationtype.typecolor if self.notificationtype else 'info' raw_color = self.notificationtype.typecolor if self.notificationtype else 'info'
# Use mapped color if it's a Bootstrap name, otherwise use as-is (hex) # 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') 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 # For recognition notifications, include employee name (or SSO as fallback) in title
title = self.title title = self.title
if raw_color == 'recognition': if raw_color == 'recognition':
employee_display = self.employeename or self.employeesso employee_display = self.employeename or self.employeesso
if employee_display: if employee_display:
title = f"{employee_display}: {title}" title = f"{employee_display}: {title}"
return { return {
'id': self.notificationid, 'id': self.notificationid,
'title': title, 'title': title,
'start': self.starttime.isoformat() if self.starttime else None, 'start': self.starttime.isoformat() if self.starttime else None,
'end': self.endtime.isoformat() if self.endtime else None, 'end': self.endtime.isoformat() if self.endtime else None,
'allDay': True, 'allDay': True,
'backgroundColor': color, 'backgroundColor': color,
'borderColor': color, 'borderColor': color,
'extendedProps': { 'extendedProps': {
'notificationid': self.notificationid, 'notificationid': self.notificationid,
'message': self.notification, 'message': self.notification,
'typename': self.notificationtype.typename if self.notificationtype else None, 'typename': self.notificationtype.typename if self.notificationtype else None,
'typecolor': raw_color, 'typecolor': raw_color,
'linkurl': self.link, 'linkurl': self.link,
'ticketnumber': self.ticketnumber, 'ticketnumber': self.ticketnumber,
'employeename': self.employeename, 'employeename': self.employeename,
'employeesso': self.employeesso, 'employeesso': self.employeesso,
} }
} }

View File

@@ -1,204 +1,204 @@
"""Notifications plugin main class.""" """Notifications plugin main class."""
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional, Type from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint from flask import Flask, Blueprint
import click import click
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.api import db
from .models import Notification, NotificationType from .models import Notification, NotificationType
from .api import notifications_bp from .api import notifications_bp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class NotificationsPlugin(BasePlugin): class NotificationsPlugin(BasePlugin):
""" """
Notifications plugin - manages announcements and notifications. Notifications plugin - manages announcements and notifications.
Provides functionality for: Provides functionality for:
- Creating and managing notifications/announcements - Creating and managing notifications/announcements
- Displaying banner notifications - Displaying banner notifications
- Calendar view of notifications - Calendar view of notifications
""" """
def __init__(self): def __init__(self):
self._manifest = self._load_manifest() self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict: def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file.""" """Load plugin manifest from JSON file."""
manifest_path = Path(__file__).parent / 'manifest.json' manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists(): if manifest_path.exists():
with open(manifest_path, 'r') as f: with open(manifest_path, 'r') as f:
return json.load(f) return json.load(f)
return {} return {}
@property @property
def meta(self) -> PluginMeta: def meta(self) -> PluginMeta:
"""Return plugin metadata.""" """Return plugin metadata."""
return PluginMeta( return PluginMeta(
name=self._manifest.get('name', 'notifications'), name=self._manifest.get('name', 'notifications'),
version=self._manifest.get('version', '1.0.0'), version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get( description=self._manifest.get(
'description', 'description',
'Notifications and announcements management' 'Notifications and announcements management'
), ),
author=self._manifest.get('author', 'ShopDB Team'), author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []), dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=1.0.0'), core_version=self._manifest.get('core_version', '>=1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/notifications'), api_prefix=self._manifest.get('api_prefix', '/api/notifications'),
) )
def get_blueprint(self) -> Optional[Blueprint]: def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes.""" """Return Flask Blueprint with API routes."""
return notifications_bp return notifications_bp
def get_models(self) -> List[Type]: def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes.""" """Return list of SQLAlchemy model classes."""
return [Notification, NotificationType] return [Notification, NotificationType]
def init_app(self, app: Flask, db_instance) -> None: def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app.""" """Initialize plugin with Flask app."""
logger.info(f"Notifications plugin initialized (v{self.meta.version})") logger.info(f"Notifications plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None: def on_install(self, app: Flask) -> None:
"""Called when plugin is installed.""" """Called when plugin is installed."""
with app.app_context(): with app.app_context():
self._ensure_notification_types() self._ensure_notification_types()
logger.info("Notifications plugin installed") logger.info("Notifications plugin installed")
def _ensure_notification_types(self) -> None: def _ensure_notification_types(self) -> None:
"""Ensure default notification types exist.""" """Ensure default notification types exist."""
default_types = [ default_types = [
('Awareness', 'General awareness notification', '#17a2b8', 'info-circle'), ('Awareness', 'General awareness notification', '#17a2b8', 'info-circle'),
('Change', 'Planned change notification', '#ffc107', 'exchange-alt'), ('Change', 'Planned change notification', '#ffc107', 'exchange-alt'),
('Incident', 'Incident or outage notification', '#dc3545', 'exclamation-triangle'), ('Incident', 'Incident or outage notification', '#dc3545', 'exclamation-triangle'),
('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'wrench'), ('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'wrench'),
('General', 'General announcement', '#28a745', 'bullhorn'), ('General', 'General announcement', '#28a745', 'bullhorn'),
] ]
for typename, description, color, icon in default_types: for typename, description, color, icon in default_types:
existing = NotificationType.query.filter_by(typename=typename).first() existing = NotificationType.query.filter_by(typename=typename).first()
if not existing: if not existing:
t = NotificationType( t = NotificationType(
typename=typename, typename=typename,
description=description, description=description,
color=color, color=color,
icon=icon icon=icon
) )
db.session.add(t) db.session.add(t)
logger.debug(f"Created notification type: {typename}") logger.debug(f"Created notification type: {typename}")
db.session.commit() db.session.commit()
def on_uninstall(self, app: Flask) -> None: def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled.""" """Called when plugin is uninstalled."""
logger.info("Notifications plugin uninstalled") logger.info("Notifications plugin uninstalled")
def get_cli_commands(self) -> List: def get_cli_commands(self) -> List:
"""Return CLI commands for this plugin.""" """Return CLI commands for this plugin."""
@click.group('notifications') @click.group('notifications')
def notifications_cli(): def notifications_cli():
"""Notifications plugin commands.""" """Notifications plugin commands."""
pass pass
@notifications_cli.command('list-types') @notifications_cli.command('list-types')
def list_types(): def list_types():
"""List all notification types.""" """List all notification types."""
from flask import current_app from flask import current_app
with current_app.app_context(): with current_app.app_context():
types = NotificationType.query.filter_by(isactive=True).all() types = NotificationType.query.filter_by(isactive=True).all()
if not types: if not types:
click.echo('No notification types found.') click.echo('No notification types found.')
return return
click.echo('Notification Types:') click.echo('Notification Types:')
for t in types: for t in types:
click.echo(f" [{t.notificationtypeid}] {t.typename} ({t.color})") click.echo(f" [{t.notificationtypeid}] {t.typename} ({t.color})")
@notifications_cli.command('stats') @notifications_cli.command('stats')
def stats(): def stats():
"""Show notification statistics.""" """Show notification statistics."""
from flask import current_app from flask import current_app
from datetime import datetime from datetime import datetime
with current_app.app_context(): with current_app.app_context():
now = datetime.utcnow() now = datetime.utcnow()
total = Notification.query.filter( total = Notification.query.filter(
Notification.isactive == True Notification.isactive == True
).count() ).count()
active = Notification.query.filter( active = Notification.query.filter(
Notification.isactive == True, Notification.isactive == True,
Notification.startdate <= now, Notification.startdate <= now,
db.or_( db.or_(
Notification.enddate.is_(None), Notification.enddate.is_(None),
Notification.enddate >= now Notification.enddate >= now
) )
).count() ).count()
click.echo(f"Total notifications: {total}") click.echo(f"Total notifications: {total}")
click.echo(f"Currently active: {active}") click.echo(f"Currently active: {active}")
@notifications_cli.command('create') @notifications_cli.command('create')
@click.option('--title', required=True, help='Notification title') @click.option('--title', required=True, help='Notification title')
@click.option('--message', required=True, help='Notification message') @click.option('--message', required=True, help='Notification message')
@click.option('--type', 'type_name', default='General', help='Notification type') @click.option('--type', 'type_name', default='General', help='Notification type')
def create_notification(title, message, type_name): def create_notification(title, message, type_name):
"""Create a new notification.""" """Create a new notification."""
from flask import current_app from flask import current_app
with current_app.app_context(): with current_app.app_context():
ntype = NotificationType.query.filter_by(typename=type_name).first() ntype = NotificationType.query.filter_by(typename=type_name).first()
if not ntype: if not ntype:
click.echo(f"Error: Notification type '{type_name}' not found.") click.echo(f"Error: Notification type '{type_name}' not found.")
return return
n = Notification( n = Notification(
title=title, title=title,
message=message, message=message,
notificationtypeid=ntype.notificationtypeid notificationtypeid=ntype.notificationtypeid
) )
db.session.add(n) db.session.add(n)
db.session.commit() db.session.commit()
click.echo(f"Created notification #{n.notificationid}: {title}") click.echo(f"Created notification #{n.notificationid}: {title}")
return [notifications_cli] return [notifications_cli]
def get_dashboard_widgets(self) -> List[Dict]: def get_dashboard_widgets(self) -> List[Dict]:
"""Return dashboard widget definitions.""" """Return dashboard widget definitions."""
return [ return [
{ {
'name': 'Active Notifications', 'name': 'Active Notifications',
'component': 'NotificationsWidget', 'component': 'NotificationsWidget',
'endpoint': '/api/notifications/dashboard/summary', 'endpoint': '/api/notifications/dashboard/summary',
'size': 'small', 'size': 'small',
'position': 1, 'position': 1,
}, },
] ]
def get_navigation_items(self) -> List[Dict]: def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items.""" """Return navigation menu items."""
return [ return [
{ {
'name': 'Notifications', 'name': 'Notifications',
'icon': 'bell', 'icon': 'bell',
'route': '/notifications', 'route': '/notifications',
'position': 5, 'position': 5,
}, },
{ {
'name': 'Calendar', 'name': 'Calendar',
'icon': 'calendar', 'icon': 'calendar',
'route': '/calendar', 'route': '/calendar',
'position': 6, 'position': 6,
}, },
] ]

View File

@@ -1,9 +1,7 @@
"""Printers plugin API.""" """Printers plugin API."""
from .routes import printers_bp # Legacy Machine-based API from .asset_routes import printers_asset_bp # Asset-based API
from .asset_routes import printers_asset_bp # New Asset-based API
__all__ = [ __all__ = [
'printers_bp', # Legacy 'printers_asset_bp',
'printers_asset_bp', # New
] ]

File diff suppressed because it is too large Load Diff

View File

@@ -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('/<int:machine_id>', 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('/<int:machine_id>/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('/<int:machine_id>/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('/<int:machine_id>/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
})

View File

@@ -1 +0,0 @@
"""Printers plugin migrations."""

View File

@@ -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

View File

@@ -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()

View File

@@ -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"}

View File

@@ -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')

View File

@@ -1,10 +1,18 @@
"""Printers plugin models.""" """Printers plugin models."""
from .printer_extension import PrinterData # Legacy model for Machine-based architecture from .printer import Printer, PrinterType # Asset-based models
from .printer import Printer, PrinterType # New Asset-based models from .model_supply import ( # data-driven model -> toner/drum/waste mapping
ModelSupply,
SUPPLY_TYPES,
SUPPLY_COLORS,
CAPACITY_TIERS,
)
__all__ = [ __all__ = [
'PrinterData', # Legacy 'Printer',
'Printer', # New 'PrinterType',
'PrinterType', # New 'ModelSupply',
'SUPPLY_TYPES',
'SUPPLY_COLORS',
'CAPACITY_TIERS',
] ]

View File

@@ -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"<ModelSupply {self.partnumber} ({self.color}/{self.capacitytier})>"
def to_dict(self):
data = super().to_dict()
if self.model:
data['modelnumber'] = self.model.modelnumber
return data

View File

@@ -1,122 +1,121 @@
"""Printer plugin models - new Asset-based architecture.""" """Printer plugin models - new Asset-based architecture."""
from shopdb.extensions import db from shopdb.api import db, BaseModel
from shopdb.core.models.base import BaseModel
class PrinterType(BaseModel):
class PrinterType(BaseModel): """
""" Printer type classification.
Printer type classification.
Examples: Laser, Inkjet, Label, MFP, Plotter, etc.
Examples: Laser, Inkjet, Label, MFP, Plotter, etc. """
""" __tablename__ = 'printertypes'
__tablename__ = 'printertypes'
printertypeid = db.Column(db.Integer, primary_key=True)
printertypeid = db.Column(db.Integer, primary_key=True) printertype = db.Column(db.String(100), unique=True, nullable=False)
printertype = db.Column(db.String(100), unique=True, nullable=False) description = db.Column(db.Text)
description = db.Column(db.Text) icon = db.Column(db.String(50), comment='Icon name for UI')
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
def __repr__(self): return f"<PrinterType {self.printertype}>"
return f"<PrinterType {self.printertype}>"
class Printer(BaseModel):
class Printer(BaseModel): """
""" Printer-specific extension data (new Asset architecture).
Printer-specific extension data (new Asset architecture).
Links to core Asset table via assetid.
Links to core Asset table via assetid. Stores printer-specific fields like type, Windows name, share name, etc.
Stores printer-specific fields like type, Windows name, share name, etc. """
""" __tablename__ = 'printers'
__tablename__ = 'printers'
printerid = db.Column(db.Integer, primary_key=True)
printerid = db.Column(db.Integer, primary_key=True)
# Link to core asset
# Link to core asset assetid = db.Column(
assetid = db.Column( db.Integer,
db.Integer, db.ForeignKey('assets.assetid', ondelete='CASCADE'),
db.ForeignKey('assets.assetid', ondelete='CASCADE'), unique=True,
unique=True, nullable=False,
nullable=False, index=True
index=True )
)
# Printer classification
# Printer classification printertypeid = db.Column(
printertypeid = db.Column( db.Integer,
db.Integer, db.ForeignKey('printertypes.printertypeid'),
db.ForeignKey('printertypes.printertypeid'), nullable=True
nullable=True )
)
# Vendor
# Vendor vendorid = db.Column(
vendorid = db.Column( db.Integer,
db.Integer, db.ForeignKey('vendors.vendorid'),
db.ForeignKey('vendors.vendorid'), nullable=True
nullable=True )
) modelnumberid = db.Column(
modelnumberid = db.Column( db.Integer,
db.Integer, db.ForeignKey('models.modelnumberid'),
db.ForeignKey('models.modelnumberid'), nullable=True
nullable=True )
)
# Network identity
# Network identity hostname = db.Column(
hostname = db.Column( db.String(100),
db.String(100), index=True,
index=True, comment='Network hostname'
comment='Network hostname' )
)
# Windows/Network naming
# Windows/Network naming windowsname = db.Column(
windowsname = db.Column( db.String(255),
db.String(255), comment='Windows printer name (e.g., \\\\server\\printer)'
comment='Windows printer name (e.g., \\\\server\\printer)' )
) sharename = db.Column(
sharename = db.Column( db.String(100),
db.String(100), comment='CSF/share name'
comment='CSF/share name' )
)
# Installation
# Installation iscsf = db.Column(db.Boolean, default=False, comment='Is CSF printer')
iscsf = db.Column(db.Boolean, default=False, comment='Is CSF printer') installpath = db.Column(db.String(255), comment='Driver install path')
installpath = db.Column(db.String(255), comment='Driver install path')
# Printer PIN (for secure print)
# Printer PIN (for secure print) pin = db.Column(db.String(20))
pin = db.Column(db.String(20))
# Features
# Features iscolor = db.Column(db.Boolean, default=False, comment='Color capable')
iscolor = db.Column(db.Boolean, default=False, comment='Color capable') isduplex = db.Column(db.Boolean, default=False, comment='Duplex capable')
isduplex = db.Column(db.Boolean, default=False, comment='Duplex capable') isnetwork = db.Column(db.Boolean, default=True, comment='Network connected')
isnetwork = db.Column(db.Boolean, default=True, comment='Network connected')
# Relationships
# Relationships asset = db.relationship(
asset = db.relationship( 'Asset',
'Asset', backref=db.backref('printer', uselist=False, lazy='joined')
backref=db.backref('printer', uselist=False, lazy='joined') )
) printertype = db.relationship('PrinterType', backref='printers')
printertype = db.relationship('PrinterType', backref='printers') vendor = db.relationship('Vendor', backref='printer_items')
vendor = db.relationship('Vendor', backref='printer_items') model = db.relationship('Model', backref='printer_items')
model = db.relationship('Model', backref='printer_items')
__table_args__ = (
__table_args__ = ( db.Index('idx_printer_type', 'printertypeid'),
db.Index('idx_printer_type', 'printertypeid'), db.Index('idx_printer_hostname', 'hostname'),
db.Index('idx_printer_hostname', 'hostname'), db.Index('idx_printer_windowsname', 'windowsname'),
db.Index('idx_printer_windowsname', 'windowsname'), )
)
def __repr__(self):
def __repr__(self): return f"<Printer {self.hostname or self.assetid}>"
return f"<Printer {self.hostname or self.assetid}>"
def to_dict(self):
def to_dict(self): """Convert to dictionary with related names."""
"""Convert to dictionary with related names.""" result = super().to_dict()
result = super().to_dict()
# Add related object names
# Add related object names if self.printertype:
if self.printertype: result['printertypename'] = self.printertype.printertype
result['printertypename'] = self.printertype.printertype if self.vendor:
if self.vendor: result['vendorname'] = self.vendor.vendor
result['vendorname'] = self.vendor.vendor if self.model:
if self.model: result['modelname'] = self.model.modelnumber
result['modelname'] = self.model.modelnumber
return result
return result

View File

@@ -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"<PrinterData machineid={self.machineid}>"

View File

@@ -9,12 +9,10 @@ from flask import Flask, Blueprint
import click import click
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.api import db, AssetType
from shopdb.core.models.machine import MachineType
from shopdb.core.models import AssetType
from .models import PrinterData, Printer, PrinterType from .models import Printer, PrinterType, ModelSupply
from .api import printers_bp, printers_asset_bp from .api import printers_asset_bp
from .services import ZabbixService from .services import ZabbixService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -74,9 +72,9 @@ class PrintersPlugin(BasePlugin):
def get_models(self) -> List[Type]: def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes.""" """Return list of SQLAlchemy model classes."""
return [ return [
PrinterData, # Legacy Machine-based Printer, # Asset-based
Printer, # New Asset-based PrinterType, # printer type classification
PrinterType, # New printer type classification ModelSupply, # model -> toner/drum/waste part numbers
] ]
def get_services(self) -> Dict[str, Type]: def get_services(self) -> Dict[str, Type]:
@@ -97,9 +95,6 @@ class PrintersPlugin(BasePlugin):
app.config.setdefault('ZABBIX_URL', '') app.config.setdefault('ZABBIX_URL', '')
app.config.setdefault('ZABBIX_TOKEN', '') 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})") logger.info(f"Printers plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None: def on_install(self, app: Flask) -> None:
@@ -107,7 +102,6 @@ class PrintersPlugin(BasePlugin):
with app.app_context(): with app.app_context():
self._ensure_asset_type() self._ensure_asset_type()
self._ensure_printer_types() self._ensure_printer_types()
self._ensure_legacy_machine_types()
logger.info("Printers plugin installed") logger.info("Printers plugin installed")
def _ensure_asset_type(self) -> None: def _ensure_asset_type(self) -> None:
@@ -131,6 +125,7 @@ class PrintersPlugin(BasePlugin):
('Laser', 'Standard laser printer', 'printer'), ('Laser', 'Standard laser printer', 'printer'),
('Inkjet', 'Inkjet printer', 'printer'), ('Inkjet', 'Inkjet printer', 'printer'),
('Label', 'Label/barcode printer', 'barcode'), ('Label', 'Label/barcode printer', 'barcode'),
('Card', 'ID / card printer', 'id-card'),
('MFP', 'Multifunction printer with scan/copy/fax', 'printer'), ('MFP', 'Multifunction printer with scan/copy/fax', 'printer'),
('Plotter', 'Large format plotter', 'drafting-compass'), ('Plotter', 'Large format plotter', 'drafting-compass'),
('Thermal', 'Thermal printer', 'temperature-high'), ('Thermal', 'Thermal printer', 'temperature-high'),
@@ -151,30 +146,6 @@ class PrintersPlugin(BasePlugin):
db.session.commit() 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: def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled.""" """Called when plugin is uninstalled."""
logger.info("Printers plugin uninstalled") logger.info("Printers plugin uninstalled")
@@ -209,6 +180,19 @@ class PrintersPlugin(BasePlugin):
for supply in supplies: for supply in supplies:
click.echo(f" {supply['name']}: {supply['level']}%") 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] return [printerscli]
def get_dashboard_widgets(self) -> List[Dict]: def get_dashboard_widgets(self) -> List[Dict]:

View File

@@ -1,5 +1,19 @@
"""Printers plugin services.""" """Printers plugin services."""
from .zabbix_service import ZabbixService 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',
]

View File

@@ -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}

View File

@@ -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]

View File

@@ -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 <token> 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 import logging
from typing import Dict, List, Optional from typing import Dict, List, Optional
@@ -6,59 +24,65 @@ from typing import Dict, List, Optional
import requests import requests
from flask import current_app from flask import current_app
from shopdb.extensions import cache from shopdb.api import cache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ZabbixService: class ZabbixService:
""" """Zabbix API client for printer supply and ping lookups."""
Zabbix API service for real-time printer supply lookups.
Queries Zabbix by IP address to get current supply levels. CACHE_TTL = 300 # 5 min, matches the classic Application cache
Use getsuppliesbyip_cached() for cached lookups or REACHABLE_CHECK_TTL = 60
getsuppliesbyip() for live data.
Configuration: # quick fail for the reachability probe
ZABBIX_ENABLED: Set to True to enable Zabbix integration (default: False) REACHABLE_TIMEOUT = 1.0
ZABBIX_URL: Zabbix API URL (e.g., http://zabbix.example.com:8080) # (connect, read) for real API calls; item.get is slow, give it room
ZABBIX_TOKEN: Zabbix API authentication token API_TIMEOUT = (3.0, 5.0)
"""
CACHE_TTL = 600 # 10 minutes # supply-level item tags, mirrors zabbix.asp GetPrinterTonerLevels
REACHABLE_CHECK_TTL = 60 # Check reachability every 60 seconds SUPPLY_TAGS = [
{"tag": "component", "value": "supplies", "operator": 0},
{"tag": "type", "value": "level", "operator": 0},
]
def __init__(self): def __init__(self):
self._url = None self._url = None
self._token = None self._token = None
self._enabled = None
# -- configuration -------------------------------------------------------
@property @property
def isenabled(self) -> bool: def isenabled(self) -> bool:
"""Check if Zabbix integration is enabled.""" """Whether the integration is switched on."""
# Check database setting first, fall back to env var from shopdb.api import Setting
from shopdb.core.models import Setting
db_enabled = Setting.get('zabbix_enabled') db_enabled = Setting.get('zabbix_enabled')
if db_enabled is not None: if db_enabled is not None:
return bool(db_enabled) return bool(db_enabled)
# Fall back to env var for backwards compatibility
return current_app.config.get('ZABBIX_ENABLED', False) return current_app.config.get('ZABBIX_ENABLED', False)
@property @property
def isconfigured(self) -> bool: def isconfigured(self) -> bool:
"""Check if Zabbix is enabled and configured.""" """Enabled, and a URL plus token are present."""
if not self.isenabled: if not self.isenabled:
return False return False
# Check database settings first, fall back to env vars from shopdb.api import Setting
from shopdb.core.models import Setting
self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL') 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') self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN')
return bool(self._url and self._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 @property
def isreachable(self) -> bool: def isreachable(self) -> bool:
"""Check if Zabbix is reachable (cached for 60 seconds).""" """Cheap connectivity probe, cached for 60s."""
if not self.isenabled or not self.isconfigured: if not self.isconfigured:
return False return False
cache_key = 'zabbix_reachable' cache_key = 'zabbix_reachable'
@@ -66,138 +90,165 @@ class ZabbixService:
if cached is not None: if cached is not None:
return cached return cached
# Quick connectivity check with 500ms timeout
try: try:
response = requests.get( response = requests.get(self.endpoint, timeout=self.REACHABLE_TIMEOUT)
f"{self._url}/api_jsonrpc.php", # any non-5xx answer means the web tier responded, so the server is
timeout=0.5 # 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 in (200, 401, 403, 405) reachable = response.status_code < 500
except requests.RequestException: except requests.RequestException:
reachable = False reachable = False
cache.set(cache_key, reachable, timeout=self.REACHABLE_CHECK_TTL) 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 return reachable
def _apicall(self, method: str, params: Dict) -> Optional[Dict]: # -- low level call ------------------------------------------------------
"""Make a Zabbix API 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: if not self.isconfigured:
return None return None
payload = { payload = {
'jsonrpc': '2.0', "jsonrpc": "2.0",
'method': method, "method": method,
'params': params, "params": params,
'auth': self._token, "id": 1,
'id': 1 }
headers = {
"Content-Type": "application/json-rpc",
"Authorization": f"Bearer {self._token}",
} }
try: try:
response = requests.post( response = requests.post(
f"{self._url}/api_jsonrpc.php", self.endpoint,
json=payload, json=payload,
headers={'Content-Type': 'application/json'}, headers=headers,
timeout=0.5 # 500ms timeout - fail fast if Zabbix is slow/unreachable timeout=self.API_TIMEOUT,
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
except (requests.RequestException, ValueError) as exc:
if 'error' in data: logger.error("Zabbix %s call failed: %s", method, exc)
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}")
return None return None
def gethostbyip(self, ip: str) -> Optional[Dict]: if "error" in data:
"""Find a Zabbix host by IP address.""" logger.error("Zabbix %s error: %s", method, data["error"])
result = self._apicall('host.get', { return None
'output': ['hostid', 'host', 'name'],
'filter': {'ip': ip},
'selectInterfaces': ['ip']
})
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: if result:
return result[0] if result else None return result[0].get("hostid")
return None return None
def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]: def _extract_color(self, item: Dict) -> str:
""" """Pull and normalise the color tag, falling back to the item name."""
Get printer supply levels by IP address. 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 hostid = self.gethostidbyip(ip)
host = self.gethostbyip(ip) if not hostid:
if not host: logger.debug("No Zabbix host for IP %s", ip)
logger.debug(f"No Zabbix host found for IP {ip}")
return None return None
hostid = host['hostid'] items = self._apicall("item.get", {
"output": ["itemid", "name", "lastvalue", "lastclock",
# Get supply-related items "units", "status", "state"],
items = self._apicall('item.get', { "hostids": hostid,
'output': ['itemid', 'name', 'lastvalue', 'key_'], "selectTags": "extend",
'hostids': hostid, "evaltype": 0, # and
'search': { "tags": self.SUPPLY_TAGS,
'key_': 'supply' # Common key pattern for printer supplies "sortfield": "name",
}, "monitored": True,
'searchWildcardsEnabled': 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: if not items:
return [] return []
supplies = [] supplies = []
for item in items: 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: try:
level = int(float(item.get('lastvalue', 0))) level = int(float(item.get("lastvalue", 0)))
except (ValueError, TypeError): except (ValueError, TypeError):
level = 0 level = 0
supplies.append({ supplies.append({
'name': item.get('name', 'Unknown'), "name": item.get("name", "Unknown"),
'level': level, "level": level,
'itemid': item.get('itemid'), "color": self._extract_color(item),
'key': item.get('key_'), "itemid": item.get("itemid"),
}) })
return supplies return supplies
def gethostid(self, ip: str) -> Optional[str]: def getpingstatus(self, ip: str) -> str:
"""Get Zabbix host ID for an IP address.""" """ICMP ping state for a printer: '1' up, '0' down, '-1' unknown."""
host = self.gethostbyip(ip) hostid = self.gethostidbyip(ip)
return host['hostid'] if host else None 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]]: def getsuppliesbyip_cached(self, ip: str) -> Optional[List[Dict]]:
"""Get printer supply levels with caching (10-minute TTL).""" """getsuppliesbyip with a 5-minute per-IP cache."""
cache_key = f'zabbix_supplies_{ip}' cache_key = f"zabbix_supplies_{ip}"
result = cache.get(cache_key) result = cache.get(cache_key)
if result is not None: if result is not None:
return result return result
result = self.getsuppliesbyip(ip) result = self.getsuppliesbyip(ip)
if result is not None: if result is not None:
cache.set(cache_key, result, timeout=self.CACHE_TTL) cache.set(cache_key, result, timeout=self.CACHE_TTL)
return result return result
def clearcache(self, ip: str = None): 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: if ip:
cache.delete(f'zabbix_supplies_{ip}') cache.delete(f"zabbix_supplies_{ip}")
cache.delete('printers_low_supplies') cache.delete("printers_low_supplies")
cache.delete("zabbix_reachable")

View File

@@ -4,15 +4,7 @@ from flask import Blueprint, request
from flask_jwt_extended import jwt_required, get_jwt_identity from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime from datetime import datetime
from shopdb.extensions import db from shopdb.api import db, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
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 ..models import USBDevice, USBDeviceType, USBCheckout from ..models import USBDevice, USBDeviceType, USBCheckout

View File

@@ -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

View File

@@ -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()

View File

@@ -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"}

View File

@@ -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')

View File

@@ -1,167 +1,166 @@
"""USB device plugin models.""" """USB device plugin models."""
from datetime import datetime from datetime import datetime
from shopdb.extensions import db from shopdb.api import db, BaseModel, AuditMixin
from shopdb.core.models.base import BaseModel, AuditMixin
class USBDeviceType(BaseModel):
class USBDeviceType(BaseModel): """
""" USB device type classification.
USB device type classification.
Examples: Flash Drive, External HDD, External SSD, Card Reader
Examples: Flash Drive, External HDD, External SSD, Card Reader """
""" __tablename__ = 'usbdevicetypes'
__tablename__ = 'usbdevicetypes'
usbdevicetypeid = db.Column(db.Integer, primary_key=True)
usbdevicetypeid = db.Column(db.Integer, primary_key=True) typename = db.Column(db.String(50), unique=True, nullable=False)
typename = db.Column(db.String(50), unique=True, nullable=False) description = db.Column(db.Text)
description = db.Column(db.Text) icon = db.Column(db.String(50), default='usb', comment='Icon name for UI')
icon = db.Column(db.String(50), default='usb', comment='Icon name for UI')
def __repr__(self):
def __repr__(self): return f"<USBDeviceType {self.typename}>"
return f"<USBDeviceType {self.typename}>"
class USBDevice(BaseModel, AuditMixin):
class USBDevice(BaseModel, AuditMixin): """
""" USB device model.
USB device model.
Tracks USB storage devices that can be checked out by users.
Tracks USB storage devices that can be checked out by users. """
""" __tablename__ = 'usbdevices'
__tablename__ = 'usbdevices'
usbdeviceid = db.Column(db.Integer, primary_key=True)
usbdeviceid = db.Column(db.Integer, primary_key=True)
# Identification
# Identification serialnumber = db.Column(db.String(100), unique=True, nullable=False)
serialnumber = db.Column(db.String(100), unique=True, nullable=False) label = db.Column(db.String(100), nullable=True, comment='Human-readable label')
label = db.Column(db.String(100), nullable=True, comment='Human-readable label') assetnumber = db.Column(db.String(50), nullable=True, comment='Optional asset tag')
assetnumber = db.Column(db.String(50), nullable=True, comment='Optional asset tag')
# Classification
# Classification usbdevicetypeid = db.Column(
usbdevicetypeid = db.Column( db.Integer,
db.Integer, db.ForeignKey('usbdevicetypes.usbdevicetypeid'),
db.ForeignKey('usbdevicetypes.usbdevicetypeid'), nullable=True
nullable=True )
)
# Specifications
# Specifications capacitygb = db.Column(db.Integer, nullable=True, comment='Capacity in GB')
capacitygb = db.Column(db.Integer, nullable=True, comment='Capacity in GB') vendorid = db.Column(db.String(10), nullable=True, comment='USB Vendor ID (hex)')
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)')
productid = db.Column(db.String(10), nullable=True, comment='USB Product ID (hex)') manufacturer = db.Column(db.String(100), nullable=True)
manufacturer = db.Column(db.String(100), nullable=True) productname = db.Column(db.String(100), nullable=True)
productname = db.Column(db.String(100), nullable=True)
# Current status
# Current status ischeckedout = db.Column(db.Boolean, default=False)
ischeckedout = db.Column(db.Boolean, default=False) currentuserid = db.Column(db.String(50), nullable=True, comment='SSO of current user')
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')
currentusername = db.Column(db.String(100), nullable=True, comment='Name of current user') currentcheckoutdate = db.Column(db.DateTime, nullable=True)
currentcheckoutdate = db.Column(db.DateTime, nullable=True)
# Location
# Location storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out')
storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out')
# Security
# Security pin = db.Column(db.String(50), nullable=True, comment='PIN for encrypted devices')
pin = db.Column(db.String(50), nullable=True, comment='PIN for encrypted devices')
# Notes
# Notes notes = db.Column(db.Text, nullable=True)
notes = db.Column(db.Text, nullable=True)
# Relationships
# Relationships devicetype = db.relationship('USBDeviceType', backref='devices')
devicetype = db.relationship('USBDeviceType', backref='devices')
# Indexes
# Indexes __table_args__ = (
__table_args__ = ( db.Index('idx_usb_serial', 'serialnumber'),
db.Index('idx_usb_serial', 'serialnumber'), db.Index('idx_usb_checkedout', 'ischeckedout'),
db.Index('idx_usb_checkedout', 'ischeckedout'), db.Index('idx_usb_type', 'usbdevicetypeid'),
db.Index('idx_usb_type', 'usbdevicetypeid'), db.Index('idx_usb_currentuser', 'currentuserid'),
db.Index('idx_usb_currentuser', 'currentuserid'), )
)
def __repr__(self):
def __repr__(self): return f"<USBDevice {self.label or self.serialnumber}>"
return f"<USBDevice {self.label or self.serialnumber}>"
@property
@property def display_name(self):
def display_name(self): """Get display name (label if set, otherwise serial number)."""
"""Get display name (label if set, otherwise serial number).""" return self.label or self.serialnumber
return self.label or self.serialnumber
def to_dict(self):
def to_dict(self): """Convert to dictionary with related data."""
"""Convert to dictionary with related data.""" result = super().to_dict()
result = super().to_dict()
# Add type info
# Add type info if self.devicetype:
if self.devicetype: result['typename'] = self.devicetype.typename
result['typename'] = self.devicetype.typename result['typeicon'] = self.devicetype.icon
result['typeicon'] = self.devicetype.icon
# Add computed property
# Add computed property result['displayname'] = self.display_name
result['displayname'] = self.display_name
return result
return result
class USBCheckout(BaseModel):
class USBCheckout(BaseModel): """
""" USB device checkout history.
USB device checkout history.
Tracks when devices are checked out and returned.
Tracks when devices are checked out and returned. Maps to existing usbcheckouts table from classic ShopDB.
Maps to existing usbcheckouts table from classic ShopDB. """
""" __tablename__ = 'usbcheckouts'
__tablename__ = 'usbcheckouts'
checkoutid = db.Column(db.Integer, primary_key=True)
checkoutid = db.Column(db.Integer, primary_key=True)
# Device reference (new column linking to usbdevices table)
# Device reference (new column linking to usbdevices table) usbdeviceid = db.Column(
usbdeviceid = db.Column( db.Integer,
db.Integer, db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'),
db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'), nullable=True
nullable=True )
)
# Legacy reference to machines table (kept for backward compatibility)
# Legacy reference to machines table (kept for backward compatibility) machineid = db.Column(db.Integer, nullable=False)
machineid = db.Column(db.Integer, nullable=False)
# User info
# User info sso = db.Column(db.String(20), nullable=False, comment='SSO of user')
sso = db.Column(db.String(20), nullable=False, comment='SSO of user') checkoutname = db.Column(db.String(100), nullable=True, comment='Name of user')
checkoutname = db.Column(db.String(100), nullable=True, comment='Name of user')
# Checkout details
# Checkout details checkouttime = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
checkouttime = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) checkintime = db.Column(db.DateTime, nullable=True)
checkintime = db.Column(db.DateTime, nullable=True)
# Metadata
# Metadata checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout')
checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout') checkinnotes = db.Column(db.Text, nullable=True)
checkinnotes = db.Column(db.Text, nullable=True) waswiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return')
waswiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return')
# Relationships
# Relationships device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic'))
device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic'))
def __repr__(self):
def __repr__(self): return f"<USBCheckout device={self.usbdeviceid} user={self.sso}>"
return f"<USBCheckout device={self.usbdeviceid} user={self.sso}>"
@property
@property def is_active(self):
def is_active(self): """Check if this checkout is currently active (not returned)."""
"""Check if this checkout is currently active (not returned).""" return self.checkintime is None
return self.checkintime is None
@property
@property def duration_days(self):
def duration_days(self): """Get duration of checkout in days."""
"""Get duration of checkout in days.""" end = self.checkintime or datetime.utcnow()
end = self.checkintime or datetime.utcnow() delta = end - self.checkouttime
delta = end - self.checkouttime return delta.days
return delta.days
def to_dict(self):
def to_dict(self): """Convert to dictionary with computed fields."""
"""Convert to dictionary with computed fields.""" result = super().to_dict()
result = super().to_dict()
result['isactivecheckout'] = self.is_active
result['isactivecheckout'] = self.is_active result['durationdays'] = self.duration_days
result['durationdays'] = self.duration_days
# Add device info if loaded
# Add device info if loaded if self.device:
if self.device: result['devicelabel'] = self.device.label
result['devicelabel'] = self.device.label result['deviceserialnumber'] = self.device.serialnumber
result['deviceserialnumber'] = self.device.serialnumber
return result
return result

View File

@@ -8,7 +8,7 @@ from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.api import db
from .models import USBDevice, USBDeviceType, USBCheckout from .models import USBDevice, USBDeviceType, USBCheckout
from .api import usb_bp from .api import usb_bp

Some files were not shown because too many files have changed in this diff Show More