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>` }
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr) qrImages.value = next
} }
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

@@ -19,12 +19,11 @@
</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">
@@ -35,15 +34,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template> </template>
</div> </div>
@@ -56,8 +46,8 @@
<form @submit.prevent="save"> <form @submit.prevent="save">
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">
<label for="pctype">PC Type *</label> <label for="computertype">PC Type *</label>
<input id="pctype" v-model="form.pctype" type="text" class="form-control" required /> <input id="computertype" v-model="form.computertype" type="text" class="form-control" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="description">Description</label> <label for="description">Description</label>
@@ -74,52 +64,30 @@
</form> </form>
</div> </div>
</div> </div>
<!-- Delete Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header"><h3>Delete PC Type</h3></div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ toDelete?.pctype }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteItem">Delete</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { pctypesApi } from '../../api' import { computersApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const pcTypes = ref([]) const pcTypes = ref([])
const loading = ref(true) const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false) const showModal = ref(false)
const editing = ref(null) const editing = ref(null)
const saving = ref(false) const saving = ref(false)
const error = ref('') const error = ref('')
const showDeleteModal = ref(false) const form = ref({ computertype: '', description: '' })
const toDelete = ref(null)
const form = ref({ pctype: '', description: '' })
onMounted(() => loadData()) onMounted(() => loadData())
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const response = await pctypesApi.list({ page: page.value, perpage: perPage.value }) const response = await computersApi.types.list({ perpage: 100 })
pcTypes.value = response.data.data || [] pcTypes.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) { } catch (err) {
console.error('Error loading PC types:', err) console.error('Error loading PC types:', err)
} finally { } finally {
@@ -127,17 +95,11 @@ async function loadData() {
} }
} }
function goToPage(p) { page.value = p; loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadData()
}
function openModal(item = null) { function openModal(item = null) {
editing.value = item editing.value = item
form.value = item ? { pctype: item.pctype || '', description: item.description || '' } : { pctype: '', description: '' } form.value = item
? { computertype: item.computertype || '', description: item.description || '' }
: { computertype: '', description: '' }
error.value = '' error.value = ''
showModal.value = true showModal.value = true
} }
@@ -149,9 +111,9 @@ async function save() {
saving.value = true saving.value = true
try { try {
if (editing.value) { if (editing.value) {
await pctypesApi.update(editing.value.pctypeid, form.value) await computersApi.types.update(editing.value.computertypeid, form.value)
} else { } else {
await pctypesApi.create(form.value) await computersApi.types.create(form.value)
} }
closeModal() closeModal()
loadData() loadData()
@@ -161,17 +123,4 @@ async function save() {
saving.value = false 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> </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,7 +1,6 @@
"""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):
@@ -62,6 +61,18 @@ class Computer(BaseModel):
nullable=True nullable=True
) )
# Hardware make/model (PCs carry vendor + model like equipment)
vendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),
nullable=True
)
modelnumberid = db.Column(
db.Integer,
db.ForeignKey('models.modelnumberid'),
nullable=True
)
# Status tracking # Status tracking
loggedinuser = db.Column(db.String(100), nullable=True) loggedinuser = db.Column(db.String(100), nullable=True)
lastreporteddate = db.Column(db.DateTime, nullable=True) lastreporteddate = db.Column(db.DateTime, nullable=True)
@@ -93,6 +104,8 @@ class Computer(BaseModel):
) )
computertype = db.relationship('ComputerType', backref='computers') computertype = db.relationship('ComputerType', backref='computers')
operatingsystem = db.relationship('OperatingSystem', backref='computers') operatingsystem = db.relationship('OperatingSystem', backref='computers')
vendor = db.relationship('Vendor')
model = db.relationship('Model')
# Installed applications (one-to-many) # Installed applications (one-to-many)
installedapps = db.relationship( installedapps = db.relationship(
@@ -120,6 +133,10 @@ class Computer(BaseModel):
result['computertypename'] = self.computertype.computertype result['computertypename'] = self.computertype.computertype
if self.operatingsystem: if self.operatingsystem:
result['osname'] = self.operatingsystem.osname result['osname'] = self.operatingsystem.osname
if self.vendor:
result['vendorname'] = self.vendor.vendor
if self.model:
result['modelname'] = self.model.modelnumber
return result return result
@@ -149,6 +166,8 @@ class ComputerInstalledApp(db.Model):
db.ForeignKey('appversions.appversionid'), db.ForeignKey('appversions.appversionid'),
nullable=True nullable=True
) )
# Raw version string from automated collection (when no curated AppVersion)
installedversion = db.Column(db.String(100), nullable=True)
isactive = db.Column(db.Boolean, default=True, nullable=False) isactive = db.Column(db.Boolean, default=True, nullable=False)
installeddate = db.Column(db.DateTime, default=db.func.now()) installeddate = db.Column(db.DateTime, default=db.func.now())

View File

@@ -9,8 +9,7 @@ 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
@@ -65,6 +64,104 @@ class ComputersPlugin(BasePlugin):
"""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 get_collector_schema(self) -> Optional[Dict]:
"""Schema for the PC collector payload (matched by hostname)."""
return {
'identityfield': 'hostname',
'fields': {
'hostname': {'type': 'string', 'required': True},
'serialnumber': {'type': 'string'},
'currentuser': {'type': 'string'},
'lastboottime': {'type': 'string', 'format': 'date-time'},
'ipaddress': {'type': 'string'},
'installedsoftware': {
'type': 'array',
'items': {'name': 'string', 'version': 'string'},
},
},
}
def apply_collector_payload(self, payload: Dict) -> Dict:
"""Idempotent upsert of a PC from a collector payload (by hostname)."""
from datetime import datetime
from shopdb.api import Asset, Application, Communication, CommunicationType
warnings = []
hostname = (payload.get('hostname') or '').strip()
if not hostname:
raise ValueError('hostname is required')
comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
if not comp:
comp = (Computer.query.join(Asset, Asset.assetid == Computer.assetid)
.filter(Asset.assetnumber.ilike(hostname)).first())
action = 'updated'
if not comp:
atype = AssetType.query.filter_by(assettype='computer').first()
# statusid=1 is the first seeded asset status ("In Use"); a
# collector-discovered PC is by definition in use.
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
statusid=1)
db.session.add(asset)
db.session.flush()
comp = Computer(assetid=asset.assetid, hostname=hostname)
db.session.add(comp)
db.session.flush()
action = 'created'
comp.lastreporteddate = datetime.utcnow()
if payload.get('lastboottime'):
try:
comp.lastboottime = datetime.fromisoformat(
payload['lastboottime'].replace('Z', '+00:00'))
except (ValueError, AttributeError):
warnings.append('lastboottime not parseable')
if payload.get('currentuser'):
comp.loggedinuser = payload['currentuser']
if payload.get('serialnumber') and comp.asset:
comp.asset.serialnumber = payload['serialnumber']
if payload.get('ipaddress'):
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
primary = Communication.query.filter_by(
assetid=comp.assetid, isprimary=True).first()
if primary:
primary.ipaddress = payload['ipaddress']
elif ip_comtype:
db.session.add(Communication(
assetid=comp.assetid, comtypeid=ip_comtype.comtypeid,
ipaddress=payload['ipaddress'], isprimary=True))
for app_data in payload.get('installedsoftware', []) or []:
name = app_data.get('name')
if not name:
continue
app = Application.query.filter(Application.appname.ilike(name)).first()
if not app:
warnings.append(f'unknown application: {name}')
continue
installed = ComputerInstalledApp.query.filter_by(
computerid=comp.computerid, appid=app.appid).first()
version = app_data.get('version')
if installed:
installed.installedversion = version
installed.isactive = True
else:
db.session.add(ComputerInstalledApp(
computerid=comp.computerid, appid=app.appid,
installedversion=version))
db.session.commit()
return {
'action': action,
'assetid': comp.assetid,
'identityvalue': hostname,
'warnings': warnings,
}
def on_install(self, app: Flask) -> None: 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():
@@ -144,7 +241,7 @@ class ComputersPlugin(BasePlugin):
def stats(): def stats():
"""Show computer statistics.""" """Show computer statistics."""
from flask import current_app from flask import current_app
from shopdb.core.models import Asset from shopdb.api import Asset
with current_app.app_context(): with current_app.app_context():
total = db.session.query(Computer).join(Asset).filter( total = db.session.query(Computer).join(Asset).filter(

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,7 +1,6 @@
"""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):

View File

@@ -9,8 +9,7 @@ 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
@@ -170,7 +169,7 @@ class EquipmentPlugin(BasePlugin):
def stats(): def stats():
"""Show equipment statistics.""" """Show equipment statistics."""
from flask import current_app from flask import current_app
from shopdb.core.models import Asset from shopdb.api 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(

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,7 +1,6 @@
"""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):

View File

@@ -1,7 +1,6 @@
"""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):

View File

@@ -9,8 +9,7 @@ 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
@@ -146,7 +145,7 @@ class NetworkPlugin(BasePlugin):
def stats(): def stats():
"""Show network device statistics.""" """Show network device statistics."""
from flask import current_app from flask import current_app
from shopdb.core.models import Asset from shopdb.api 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(

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,7 +1,7 @@
"""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):

View File

@@ -9,7 +9,7 @@ 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

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

View File

@@ -5,18 +5,17 @@ import logging
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, cache from shopdb.api import db, cache, Asset, AssetType, Vendor, Model, Communication, CommunicationType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from shopdb.core.models import Asset, AssetType, Vendor, Model, Communication, CommunicationType
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import Printer, PrinterType from ..models import Printer, PrinterType, ModelSupply
from ..services import ZabbixService from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS
from ..services import (
ZabbixService,
classifysupply,
derivesupplytype,
derivecolor,
lookupsupplies,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -134,19 +133,19 @@ def list_printers():
) )
# Type filter # Type filter
if type_id := request.args.get('type_id'): if typeid := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(Printer.printertypeid == int(type_id)) query = query.filter(Printer.printertypeid == int(typeid))
# 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(Printer.vendorid == int(vendor_id)) query = query.filter(Printer.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
@@ -278,6 +277,8 @@ def create_printer():
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=printer_type.assettypeid, assettypeid=printer_type.assettypeid,
statusid=data.get('statusid', 1), statusid=data.get('statusid', 1),
locationid=data.get('locationid'), locationid=data.get('locationid'),
@@ -357,9 +358,11 @@ def update_printer(printer_id: int):
http_code=409 http_code=409
) )
# Update asset fields # Update asset fields (optional identifiers gated per-type in Settings)
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] 'maintenancereference', '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:
setattr(asset, key, data[key]) setattr(asset, key, data[key])
@@ -372,6 +375,27 @@ def update_printer(printer_id: int):
if key in data: if key in data:
setattr(printer, key, data[key]) setattr(printer, key, data[key])
# Upsert the primary IP communication when an ipaddress is supplied, so a
# single PUT updates core, extension, and network in one call.
if 'ipaddress' in data:
ip = (data.get('ipaddress') or '').strip()
comm = Communication.query.filter_by(
assetid=asset.assetid, isprimary=True).first()
if ip:
if comm:
comm.ipaddress = ip
else:
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
if ip_comtype:
db.session.add(Communication(
assetid=asset.assetid,
comtypeid=ip_comtype.comtypeid,
ipaddress=ip,
isprimary=True,
))
elif comm:
comm.ipaddress = None
db.session.commit() db.session.commit()
result = asset.to_dict() result = asset.to_dict()
@@ -426,17 +450,25 @@ def get_printer_supplies(printer_id: int):
service = ZabbixService() service = ZabbixService()
if not service.isconfigured or not service.isreachable: if not service.isconfigured or not service.isreachable:
# Return empty supplies if Zabbix not available (fail gracefully) # fail soft when zabbix off or down
return success_response({ return success_response({
'ipaddress': comm.ipaddress, 'ipaddress': comm.ipaddress,
'pingstatus': '-1',
'supplies': [] 'supplies': []
}) })
supplies = service.getsuppliesbyip(comm.ipaddress) # vendor drives waste-cartridge rules; modelnumberid drives part lookup
vendor_name = printer.vendor.vendor if getattr(printer, 'vendor', None) else None
raw_supplies = service.getsuppliesbyip(comm.ipaddress) or []
supplies = [
_annotate_supply(s, vendor_name, printer.modelnumberid) for s in raw_supplies
]
return success_response({ return success_response({
'ipaddress': comm.ipaddress, 'ipaddress': comm.ipaddress,
'supplies': supplies or [] 'pingstatus': service.getpingstatus(comm.ipaddress),
'supplies': supplies
}) })
@@ -444,8 +476,32 @@ def get_printer_supplies(printer_id: int):
# Low Supplies # Low Supplies
# ============================================================================= # =============================================================================
def _annotate_supply(supply, vendor_name, modelnumberid):
"""Add status, remaining percent, and part numbers to a raw supply dict.
Waste cartridge direction depends on vendor, so classification lives in
the supply_parts helper. Part numbers come from the modelsupplies table.
"""
level = supply.get('level', 0)
name = supply.get('name', 'Unknown')
supplytype = derivesupplytype(name)
color = derivecolor(name, supply.get('color'))
cls = classifysupply(level, name, vendor_name)
return {
'name': name,
'level': level,
'color': color,
'supplytype': supplytype,
'status': cls['status'],
'remaining': cls['remaining'],
'iswaste': cls['iswaste'],
'isdrum': cls['isdrum'],
'partnumbers': lookupsupplies(modelnumberid, color, supplytype),
}
def _get_low_supplies_data(): def _get_low_supplies_data():
"""Build low supplies data (cached for 10 minutes).""" """Build low supplies data (cached for 5 minutes)."""
cached = cache.get('printers_low_supplies') cached = cache.get('printers_low_supplies')
if cached is not None: if cached is not None:
return cached return cached
@@ -454,68 +510,65 @@ def _get_low_supplies_data():
if not service.isconfigured or not service.isreachable: if not service.isconfigured or not service.isreachable:
return {'printers': [], 'summary': {'total_checked': 0, 'low': 0, 'critical': 0}} return {'printers': [], 'summary': {'total_checked': 0, 'low': 0, 'critical': 0}}
# All active printers with an IP address # active printers with an IP, with vendor and model for waste/part rules
printers = ( rows = (
db.session.query(Printer, Asset, Communication) db.session.query(Printer, Asset, Communication, Vendor, Model)
.join(Asset, Asset.assetid == Printer.assetid) .join(Asset, Asset.assetid == Printer.assetid)
.join(Communication, Communication.assetid == Asset.assetid) .join(Communication, Communication.assetid == Asset.assetid)
.outerjoin(Vendor, Vendor.vendorid == Printer.vendorid)
.outerjoin(Model, Model.modelnumberid == Printer.modelnumberid)
.filter(Asset.isactive == True) .filter(Asset.isactive == True)
.filter(Communication.ipaddress.isnot(None)) .filter(Communication.ipaddress.isnot(None))
.filter(Communication.ipaddress != '') .filter(Communication.ipaddress != '')
.all() .all()
) )
# Dedupe by printer id (may have multiple comms) # dedupe by printer id (a printer may have several comms)
seen = set() seen = set()
unique_printers = [] unique_printers = []
for printer, asset, comm in printers: for printer, asset, comm, vendor, model in rows:
if printer.printerid not in seen: if printer.printerid not in seen:
seen.add(printer.printerid) seen.add(printer.printerid)
unique_printers.append((printer, asset, comm)) unique_printers.append((printer, asset, comm, vendor, model))
results = [] results = []
total_checked = 0 total_checked = 0
for printer, asset, comm in unique_printers: for printer, asset, comm, vendor, model in unique_printers:
supplies = service.getsuppliesbyip_cached(comm.ipaddress) supplies = service.getsuppliesbyip_cached(comm.ipaddress)
if supplies is None: if supplies is None:
continue continue
total_checked += 1 total_checked += 1
# Annotate each supply with status vendor_name = vendor.vendor if vendor else None
model_number = model.modelnumber if model else None
modelnumberid = model.modelnumberid if model else None
annotated = [] annotated = []
has_low = False has_low = False
for s in supplies: for s in supplies:
level = s.get('level', 0) item = _annotate_supply(s, vendor_name, modelnumberid)
if level <= 5: if item['status'] != 'ok':
status = 'critical'
has_low = True has_low = True
elif level <= 10: annotated.append(item)
status = 'low'
has_low = True
else:
status = 'ok'
annotated.append({
'name': s.get('name', 'Unknown'),
'level': level,
'status': status
})
if has_low: if has_low:
# Get location name # location name for the report row
location_name = None location_name = None
if asset.locationid: if asset.locationid:
from shopdb.core.models import Location from shopdb.api import Location
loc = Location.query.get(asset.locationid) loc = Location.query.get(asset.locationid)
if loc: if loc:
location_name = loc.location location_name = loc.locationname
results.append({ results.append({
'printerid': printer.printerid, 'printerid': printer.printerid,
'printername': asset.name or printer.hostname or '', 'printername': asset.name or printer.hostname or '',
'assetnumber': asset.assetnumber or '', 'assetnumber': asset.assetnumber or '',
'ipaddress': comm.ipaddress, 'ipaddress': comm.ipaddress,
'vendor': vendor_name,
'model': model_number,
'location': location_name, 'location': location_name,
'supplies': annotated 'supplies': annotated
}) })
@@ -539,7 +592,7 @@ def _get_low_supplies_data():
} }
} }
cache.set('printers_low_supplies', data, timeout=600) cache.set('printers_low_supplies', data, timeout=300)
return data return data
@@ -551,6 +604,62 @@ def low_supplies():
return success_response(data) return success_response(data)
@printers_asset_bp.route('/lookup', methods=['GET'])
@jwt_required(optional=True)
def printer_lookup():
"""Find a printer by IP or FQDN. Parity with the classic printerlookup.asp.
Zabbix uses this to jump straight to a printer record. Query with
?ip=x.x.x.x or ?fqdn=hostname; returns the matching printer id.
"""
ip = (request.args.get('ip') or '').strip()
fqdn = (request.args.get('fqdn') or '').strip()
lookup_value = ip or fqdn
if not lookup_value:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Provide ip or fqdn'
)
# match the IP against any active printer communication
row = (
db.session.query(Printer, Asset)
.join(Asset, Asset.assetid == Printer.assetid)
.join(Communication, Communication.assetid == Asset.assetid)
.filter(Asset.isactive == True)
.filter(Communication.ipaddress == lookup_value)
.first()
)
if not row:
return error_response(
ErrorCodes.NOT_FOUND,
f'Printer not found: {lookup_value}',
http_code=404
)
printer, asset = row
return success_response({
'printerid': printer.printerid,
'assetid': asset.assetid,
'assetnumber': asset.assetnumber,
'name': asset.name or printer.hostname,
})
@printers_asset_bp.route('/supplies/refresh', methods=['POST'])
@jwt_required()
def refresh_supplies_cache():
"""Clear cached Zabbix supply data so the next read pulls fresh values.
Backs the toner report Refresh button (parity with adminclearcache.asp
type=zabbix).
"""
ZabbixService().clearcache()
return success_response(message='Supply cache cleared')
# ============================================================================= # =============================================================================
# Dashboard # Dashboard
# ============================================================================= # =============================================================================
@@ -605,3 +714,213 @@ def dashboard_summary():
'bytype': [{'type': t, 'count': c} for t, c in by_type], 'bytype': [{'type': t, 'count': c} for t, c in by_type],
'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor], 'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
}) })
# =============================================================================
# Model Supplies (data-driven toner/drum/waste part numbers)
# =============================================================================
def _validate_supply_payload(data):
"""Return an error message if the supply payload is invalid, else None."""
if not data:
return 'No data provided'
if not data.get('partnumber'):
return 'partnumber is required'
supplytype = data.get('supplytype', 'toner')
if supplytype not in SUPPLY_TYPES:
return f"supplytype must be one of {', '.join(SUPPLY_TYPES)}"
color = data.get('color', 'none')
if color not in SUPPLY_COLORS:
return f"color must be one of {', '.join(SUPPLY_COLORS)}"
capacitytier = data.get('capacitytier', 'standard')
if capacitytier not in CAPACITY_TIERS:
return f"capacitytier must be one of {', '.join(CAPACITY_TIERS)}"
return None
@printers_asset_bp.route('/supplies/meta', methods=['GET'])
@jwt_required(optional=True)
def supplies_meta():
"""Allowed values for supply type, color, and capacity tier (for the UI)."""
return success_response({
'supplytypes': list(SUPPLY_TYPES),
'colors': list(SUPPLY_COLORS),
'capacitytiers': list(CAPACITY_TIERS),
})
@printers_asset_bp.route('/models', methods=['GET'])
@jwt_required(optional=True)
def list_supply_models():
"""List models with a supply count, for the supply-management picker.
Query parameters:
- search: filter by model number
- vendor_id: filter by vendor
- withsupplies: 'true' to only return models that already have supplies
"""
page, per_page = get_pagination_params(request)
supplycount = db.func.count(ModelSupply.modelsupplyid).label('supplycount')
query = (
db.session.query(Model, Vendor.vendor, supplycount)
.outerjoin(Vendor, Vendor.vendorid == Model.vendorid)
.outerjoin(ModelSupply, db.and_(
ModelSupply.modelnumberid == Model.modelnumberid,
ModelSupply.isactive == True,
))
.group_by(Model.modelnumberid, Vendor.vendor)
)
# Toner/drum/waste only apply to printers, so restrict the picker to
# printer models: those attached to a printer asset, or those that already
# carry supply mappings. Keeps machine/controller models out of the list.
printer_model_ids = (
db.session.query(Printer.modelnumberid)
.filter(Printer.modelnumberid.isnot(None))
)
supply_model_ids = db.session.query(ModelSupply.modelnumberid)
query = query.filter(db.or_(
Model.modelnumberid.in_(printer_model_ids),
Model.modelnumberid.in_(supply_model_ids),
))
if search := request.args.get('search'):
query = query.filter(Model.modelnumber.ilike(f'%{search}%'))
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
query = query.filter(Model.vendorid == int(vendor_id))
if request.args.get('withsupplies', '').lower() == 'true':
query = query.having(supplycount > 0)
query = query.order_by(Model.modelnumber)
total = query.count()
rows = query.limit(per_page).offset((page - 1) * per_page).all()
data = [{
'modelnumberid': model.modelnumberid,
'modelnumber': model.modelnumber,
'vendor': vendor,
'vendorid': model.vendorid,
'supplycount': count,
} for model, vendor, count in rows]
return paginated_response(data, page, per_page, total)
@printers_asset_bp.route('/models/<int:modelnumberid>/supplies', methods=['GET'])
@jwt_required(optional=True)
def list_model_supplies(modelnumberid: int):
"""List all supplies mapped to a model."""
model = Model.query.get(modelnumberid)
if not model:
return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404)
supplies = (
ModelSupply.query
.filter_by(modelnumberid=modelnumberid, isactive=True)
.order_by(ModelSupply.supplytype, ModelSupply.color, ModelSupply.capacitytier)
.all()
)
return success_response({
'modelnumberid': modelnumberid,
'modelnumber': model.modelnumber,
'supplies': [s.to_dict() for s in supplies],
})
@printers_asset_bp.route('/models/<int:modelnumberid>/supplies', methods=['POST'])
@jwt_required()
def create_model_supply(modelnumberid: int):
"""Add a supply to a model."""
model = Model.query.get(modelnumberid)
if not model:
return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404)
data = request.get_json()
message = _validate_supply_payload(data)
if message:
return error_response(ErrorCodes.VALIDATION_ERROR, message)
existing = ModelSupply.query.filter_by(
modelnumberid=modelnumberid,
partnumber=data['partnumber'],
).first()
if existing:
return error_response(
ErrorCodes.CONFLICT,
f"Part number '{data['partnumber']}' already mapped to this model",
http_code=409,
)
supply = ModelSupply(
modelnumberid=modelnumberid,
supplytype=data.get('supplytype', 'toner'),
color=data.get('color', 'none'),
capacitytier=data.get('capacitytier', 'standard'),
partnumber=data['partnumber'],
marketingname=data.get('marketingname'),
pageyield=data.get('pageyield'),
notes=data.get('notes'),
)
db.session.add(supply)
db.session.commit()
return success_response(supply.to_dict(), message='Supply added', http_code=201)
@printers_asset_bp.route('/supplies/<int:modelsupplyid>', methods=['PUT'])
@jwt_required()
def update_model_supply(modelsupplyid: int):
"""Update a model supply."""
supply = ModelSupply.query.get(modelsupplyid)
if not supply:
return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# validate only the fields present
merged = {
'partnumber': data.get('partnumber', supply.partnumber),
'supplytype': data.get('supplytype', supply.supplytype),
'color': data.get('color', supply.color),
'capacitytier': data.get('capacitytier', supply.capacitytier),
}
message = _validate_supply_payload(merged)
if message:
return error_response(ErrorCodes.VALIDATION_ERROR, message)
if 'partnumber' in data and data['partnumber'] != supply.partnumber:
clash = ModelSupply.query.filter_by(
modelnumberid=supply.modelnumberid,
partnumber=data['partnumber'],
).first()
if clash:
return error_response(
ErrorCodes.CONFLICT,
f"Part number '{data['partnumber']}' already mapped to this model",
http_code=409,
)
for field in ('supplytype', 'color', 'capacitytier', 'partnumber',
'marketingname', 'pageyield', 'notes'):
if field in data:
setattr(supply, field, data[field])
db.session.commit()
return success_response(supply.to_dict(), message='Supply updated')
@printers_asset_bp.route('/supplies/<int:modelsupplyid>', methods=['DELETE'])
@jwt_required()
def delete_model_supply(modelsupplyid: int):
"""Delete a model supply."""
supply = ModelSupply.query.get(modelsupplyid)
if not supply:
return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404)
db.session.delete(supply)
db.session.commit()
return success_response(message='Supply deleted')

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,7 +1,6 @@
"""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):

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 None
return data.get('result') if "error" in data:
logger.error("Zabbix %s error: %s", method, data["error"])
except requests.RequestException as e:
logger.error(f"Zabbix API request failed: {e}")
return None return None
def gethostbyip(self, ip: str) -> Optional[Dict]: return data.get("result")
"""Find a Zabbix host by IP address."""
result = self._apicall('host.get', { # -- host / item lookups -------------------------------------------------
'output': ['hostid', 'host', 'name'],
'filter': {'ip': ip}, def gethostidbyip(self, ip: str) -> Optional[str]:
'selectInterfaces': ['ip'] """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 _extract_color(self, item: Dict) -> str:
"""Pull and normalise the color tag, falling back to the item name."""
color = ""
for tag in item.get("tags", []) or []:
if tag.get("tag") == "color":
color = (tag.get("value") or "").lower()
break
if "black" in color:
color = "black"
elif color in ("grey", "gray"):
color = "gray"
if not color:
name = (item.get("name") or "").lower()
for candidate in ("cyan", "magenta", "yellow", "black"):
if candidate in name:
color = candidate
break
if not color and ("gray" in name or "grey" in name):
color = "gray"
return color
def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]: def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]:
""" """Current supply levels for a printer, by IP.
Get printer supply levels by IP address.
Returns list of supplies with name and level percentage. 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,8 +1,7 @@
"""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):

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