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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
71
frontend/src/composables/identifierSettings.js
Normal file
71
frontend/src/composables/identifierSettings.js
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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: {} } }))
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
<template v-if="page[pos - 1]">
|
<template v-if="page[pos - 1]">
|
||||||
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
|
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
|
||||||
<div class="qr-container">
|
<div class="qr-container">
|
||||||
<canvas :ref="el => setQrRef(el, pageIdx, pos)"></canvas>
|
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
|
||||||
</div>
|
</div>
|
||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
|
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
|
||||||
@@ -65,12 +65,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||||
import { printersApi } from '../../api'
|
import { printersApi } from '../../api'
|
||||||
import QRCode from 'qrcode'
|
import { renderQrDataUrl } from './qrLogo'
|
||||||
|
|
||||||
const printers = ref([])
|
const printers = ref([])
|
||||||
const selectedPrinters = ref([])
|
const selectedPrinters = ref([])
|
||||||
const loadingPrinters = ref(true)
|
const loadingPrinters = ref(true)
|
||||||
const qrRefs = ref({})
|
// QR codes rendered to data-URL images (not live <canvas>): canvases are
|
||||||
|
// unreliable in print output, images print every time.
|
||||||
|
const qrImages = ref({})
|
||||||
|
|
||||||
const pageCount = computed(() => Math.ceil(selectedPrinters.value.length / 6) || 0)
|
const pageCount = computed(() => Math.ceil(selectedPrinters.value.length / 6) || 0)
|
||||||
|
|
||||||
@@ -102,52 +104,19 @@ watch(selectedPrinters, async () => {
|
|||||||
generateQRCodes()
|
generateQRCodes()
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
function setQrRef(el, pageIdx, pos) {
|
async function generateQRCodes() {
|
||||||
if (el) {
|
const next = {}
|
||||||
qrRefs.value[`${pageIdx}-${pos}`] = el
|
for (let pageIdx = 0; pageIdx < pages.value.length; pageIdx++) {
|
||||||
}
|
const page = pages.value[pageIdx]
|
||||||
}
|
for (let idx = 0; idx < page.length; idx++) {
|
||||||
|
const printer = page[idx]
|
||||||
function generateQRCodes() {
|
if (!printer) continue
|
||||||
pages.value.forEach((page, pageIdx) => {
|
|
||||||
page.forEach((printer, idx) => {
|
|
||||||
if (!printer) return
|
|
||||||
const pos = idx + 1
|
const pos = idx + 1
|
||||||
const canvas = qrRefs.value[`${pageIdx}-${pos}`]
|
|
||||||
if (!canvas) return
|
|
||||||
|
|
||||||
const qrUrl = `${window.location.origin}/printers/${printer.printer?.printerid || printer.assetid}`
|
const qrUrl = `${window.location.origin}/printers/${printer.printer?.printerid || printer.assetid}`
|
||||||
QRCode.toCanvas(canvas, qrUrl, {
|
next[`${pageIdx}-${pos}`] = await renderQrDataUrl(qrUrl)
|
||||||
width: 144,
|
}
|
||||||
margin: 0,
|
|
||||||
errorCorrectionLevel: 'H'
|
|
||||||
}).then(() => {
|
|
||||||
drawLogoOverlay(canvas)
|
|
||||||
}).catch(err => console.error('QR error:', err))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawLogoOverlay(canvas) {
|
|
||||||
const canvasContext = canvas.getContext('2d')
|
|
||||||
const size = canvas.width
|
|
||||||
const logoSize = Math.round(size * 0.22)
|
|
||||||
const x = (size - logoSize) / 2
|
|
||||||
const y = (size - logoSize) / 2
|
|
||||||
|
|
||||||
// White circle background
|
|
||||||
canvasContext.beginPath()
|
|
||||||
canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2)
|
|
||||||
canvasContext.fillStyle = '#fff'
|
|
||||||
canvasContext.fill()
|
|
||||||
|
|
||||||
// Load and draw the GE monogram
|
|
||||||
const img = new Image()
|
|
||||||
img.onload = () => {
|
|
||||||
canvasContext.drawImage(img, 0, 0, 32.5, 32, x, y, logoSize, logoSize)
|
|
||||||
}
|
}
|
||||||
const svgStr = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>`
|
qrImages.value = next
|
||||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayName(printer) {
|
function displayName(printer) {
|
||||||
@@ -193,26 +162,27 @@ function print() {
|
|||||||
@page { size: letter; margin: 0; }
|
@page { size: letter; margin: 0; }
|
||||||
|
|
||||||
.no-print { margin-bottom: 20px; padding: 20px; }
|
.no-print { margin-bottom: 20px; padding: 20px; }
|
||||||
.controls { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
|
.controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
|
||||||
.controls h3 { margin-top: 0; }
|
.controls h3 { margin-top: 0; }
|
||||||
|
|
||||||
.print-btn {
|
.print-btn {
|
||||||
padding: 10px 30px;
|
padding: 10px 30px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: #667eea;
|
background: var(--primary);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
.print-btn:disabled { background: #ccc; cursor: not-allowed; }
|
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
|
||||||
|
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
|
||||||
|
|
||||||
.clear-btn {
|
.clear-btn {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: #dc3545;
|
background: var(--danger);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -223,7 +193,7 @@ function print() {
|
|||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: #28a745;
|
background: var(--success);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -235,31 +205,32 @@ function print() {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid var(--border);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #fafafa;
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.printer-item {
|
.printer-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: white;
|
background: var(--bg-card);
|
||||||
border: 1px solid #ddd;
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.printer-item:hover { background: #f0f0f0; }
|
.printer-item:hover { border-color: var(--primary); }
|
||||||
.printer-item.selected { background: #e7f1ff; border-color: #667eea; }
|
.printer-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
|
||||||
.printer-item input { margin-right: 10px; }
|
.printer-item input { margin-right: 10px; }
|
||||||
.printer-item label { cursor: pointer; flex: 1; }
|
.printer-item label { cursor: pointer; flex: 1; }
|
||||||
.printer-item .model { font-size: 11px; color: #666; }
|
.printer-item .model { font-size: 11px; color: var(--text-light); }
|
||||||
|
|
||||||
.selected-count { font-weight: bold; margin: 10px 0; }
|
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
|
||||||
.selected-count .count { color: #667eea; }
|
.selected-count .count { color: var(--primary); }
|
||||||
.selected-count .pages { color: #28a745; }
|
.selected-count .pages { color: var(--success); }
|
||||||
|
|
||||||
.loading-msg { text-align: center; padding: 2rem; color: #666; }
|
.loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
|
||||||
|
|
||||||
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
|
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
|
||||||
@@ -288,9 +259,11 @@ function print() {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1px dashed #ccc;
|
border: 1px dashed #ccc;
|
||||||
}
|
}
|
||||||
.label.filled { border: 2px solid #667eea; }
|
.label.filled { border: 2px solid var(--primary); }
|
||||||
.label.empty { background: #fafafa; }
|
.label.empty { background: #fafafa; }
|
||||||
|
|
||||||
|
.qr-img { width: 144px; height: 144px; display: block; }
|
||||||
|
|
||||||
.pos-1 { top: 0.875in; left: 1.1875in; }
|
.pos-1 { top: 0.875in; left: 1.1875in; }
|
||||||
.pos-2 { top: 0.875in; left: 4.3125in; }
|
.pos-2 { top: 0.875in; left: 4.3125in; }
|
||||||
.pos-3 { top: 4in; left: 1.1875in; }
|
.pos-3 { top: 4in; left: 1.1875in; }
|
||||||
@@ -307,6 +280,13 @@ function print() {
|
|||||||
.empty-label { color: #999; font-size: 14px; }
|
.empty-label { color: #999; font-size: 14px; }
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
|
/* Force the browser to print rendered images/colors even when the user's
|
||||||
|
"Background graphics" option is off. Without this the QR images and
|
||||||
|
borders can drop out of the printout. */
|
||||||
|
body, .print-sheet, .label, .qr-img, .qr-container {
|
||||||
|
-webkit-print-color-adjust: exact !important;
|
||||||
|
print-color-adjust: exact !important;
|
||||||
|
}
|
||||||
body { padding: 0; margin: 0; background: white; }
|
body { padding: 0; margin: 0; background: white; }
|
||||||
.no-print { display: none !important; }
|
.no-print { display: none !important; }
|
||||||
.sheets-container { gap: 0; }
|
.sheets-container { gap: 0; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
51
frontend/src/views/print/qrLogo.js
Normal file
51
frontend/src/views/print/qrLogo.js
Normal 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 ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
437
frontend/src/views/settings/ModelSuppliesList.vue
Normal file
437
frontend/src/views/settings/ModelSuppliesList.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
140
frontend/src/views/settings/PluginsList.vue
Normal file
140
frontend/src/views/settings/PluginsList.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 } }"
|
||||||
|
|||||||
44
migrations/versions/7b02_gaugelabreference.py
Normal file
44
migrations/versions/7b02_gaugelabreference.py
Normal 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')
|
||||||
47
migrations/versions/7c01_drop_legacy_machine.py
Normal file
47
migrations/versions/7c01_drop_legacy_machine.py
Normal 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")
|
||||||
29
migrations/versions/7c02_application_isrequired.py
Normal file
29
migrations/versions/7c02_application_isrequired.py
Normal 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')
|
||||||
56
migrations/versions/7c03_locationtypes.py
Normal file
56
migrations/versions/7c03_locationtypes.py
Normal 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')
|
||||||
85
migrations/versions/7c04_fold_plugin_schema.py
Normal file
85
migrations/versions/7c04_fold_plugin_schema.py
Normal 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')
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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"}
|
|
||||||
@@ -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')
|
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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"}
|
|
||||||
@@ -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')
|
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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"}
|
|
||||||
@@ -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')
|
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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"}
|
|
||||||
@@ -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')
|
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
|
||||||
})
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Printers plugin migrations."""
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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"}
|
|
||||||
@@ -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')
|
|
||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
56
plugins/printers/models/model_supply.py
Normal file
56
plugins/printers/models/model_supply.py
Normal 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
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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}>"
|
|
||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]
|
||||||
|
|||||||
358
plugins/printers/services/seed_supplies.py
Normal file
358
plugins/printers/services/seed_supplies.py
Normal 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}
|
||||||
107
plugins/printers/services/supply_parts.py
Normal file
107
plugins/printers/services/supply_parts.py
Normal 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]
|
||||||
@@ -1,4 +1,22 @@
|
|||||||
"""Zabbix service for real-time printer supply lookups."""
|
"""Zabbix service for real-time printer supply lookups.
|
||||||
|
|
||||||
|
Ports the classic ASP shopdb Zabbix integration (includes/zabbix.asp and
|
||||||
|
includes/zabbix_all_supplies.asp) to Python. Key behaviours preserved from
|
||||||
|
the live integration:
|
||||||
|
|
||||||
|
- Auth via an Authorization: Bearer <token> header (Zabbix 6.0+ / 7.0).
|
||||||
|
The old payload "auth" field is rejected by Zabbix 7.0.
|
||||||
|
- Hosts are named by IP address, so a host is located with
|
||||||
|
host.get filter {host: [ip]}, not by interface address.
|
||||||
|
- Supply levels come from items tagged component=supplies AND type=level,
|
||||||
|
not from a key_ substring search.
|
||||||
|
- Each level item carries a color tag used for display and part lookup.
|
||||||
|
|
||||||
|
Configuration (database Setting overrides env var):
|
||||||
|
ZABBIX_ENABLED: turn the integration on
|
||||||
|
ZABBIX_URL: base URL or full api_jsonrpc.php URL
|
||||||
|
ZABBIX_TOKEN: API token
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
@@ -6,59 +24,65 @@ from typing import Dict, List, Optional
|
|||||||
import requests
|
import requests
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
from shopdb.extensions import cache
|
from shopdb.api import cache
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ZabbixService:
|
class ZabbixService:
|
||||||
"""
|
"""Zabbix API client for printer supply and ping lookups."""
|
||||||
Zabbix API service for real-time printer supply lookups.
|
|
||||||
|
|
||||||
Queries Zabbix by IP address to get current supply levels.
|
CACHE_TTL = 300 # 5 min, matches the classic Application cache
|
||||||
Use getsuppliesbyip_cached() for cached lookups or
|
REACHABLE_CHECK_TTL = 60
|
||||||
getsuppliesbyip() for live data.
|
|
||||||
|
|
||||||
Configuration:
|
# quick fail for the reachability probe
|
||||||
ZABBIX_ENABLED: Set to True to enable Zabbix integration (default: False)
|
REACHABLE_TIMEOUT = 1.0
|
||||||
ZABBIX_URL: Zabbix API URL (e.g., http://zabbix.example.com:8080)
|
# (connect, read) for real API calls; item.get is slow, give it room
|
||||||
ZABBIX_TOKEN: Zabbix API authentication token
|
API_TIMEOUT = (3.0, 5.0)
|
||||||
"""
|
|
||||||
|
|
||||||
CACHE_TTL = 600 # 10 minutes
|
# supply-level item tags, mirrors zabbix.asp GetPrinterTonerLevels
|
||||||
REACHABLE_CHECK_TTL = 60 # Check reachability every 60 seconds
|
SUPPLY_TAGS = [
|
||||||
|
{"tag": "component", "value": "supplies", "operator": 0},
|
||||||
|
{"tag": "type", "value": "level", "operator": 0},
|
||||||
|
]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._url = None
|
self._url = None
|
||||||
self._token = None
|
self._token = None
|
||||||
self._enabled = None
|
|
||||||
|
# -- configuration -------------------------------------------------------
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def isenabled(self) -> bool:
|
def isenabled(self) -> bool:
|
||||||
"""Check if Zabbix integration is enabled."""
|
"""Whether the integration is switched on."""
|
||||||
# Check database setting first, fall back to env var
|
from shopdb.api import Setting
|
||||||
from shopdb.core.models import Setting
|
|
||||||
db_enabled = Setting.get('zabbix_enabled')
|
db_enabled = Setting.get('zabbix_enabled')
|
||||||
if db_enabled is not None:
|
if db_enabled is not None:
|
||||||
return bool(db_enabled)
|
return bool(db_enabled)
|
||||||
# Fall back to env var for backwards compatibility
|
|
||||||
return current_app.config.get('ZABBIX_ENABLED', False)
|
return current_app.config.get('ZABBIX_ENABLED', False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def isconfigured(self) -> bool:
|
def isconfigured(self) -> bool:
|
||||||
"""Check if Zabbix is enabled and configured."""
|
"""Enabled, and a URL plus token are present."""
|
||||||
if not self.isenabled:
|
if not self.isenabled:
|
||||||
return False
|
return False
|
||||||
# Check database settings first, fall back to env vars
|
from shopdb.api import Setting
|
||||||
from shopdb.core.models import Setting
|
|
||||||
self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL')
|
self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL')
|
||||||
self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN')
|
self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN')
|
||||||
return bool(self._url and self._token)
|
return bool(self._url and self._token)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def endpoint(self) -> str:
|
||||||
|
"""Full JSON-RPC endpoint. Accept a base URL or the full path."""
|
||||||
|
url = (self._url or "").rstrip("/")
|
||||||
|
if url.endswith("api_jsonrpc.php"):
|
||||||
|
return url
|
||||||
|
return f"{url}/api_jsonrpc.php"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def isreachable(self) -> bool:
|
def isreachable(self) -> bool:
|
||||||
"""Check if Zabbix is reachable (cached for 60 seconds)."""
|
"""Cheap connectivity probe, cached for 60s."""
|
||||||
if not self.isenabled or not self.isconfigured:
|
if not self.isconfigured:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
cache_key = 'zabbix_reachable'
|
cache_key = 'zabbix_reachable'
|
||||||
@@ -66,138 +90,165 @@ class ZabbixService:
|
|||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
# Quick connectivity check with 500ms timeout
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(self.endpoint, timeout=self.REACHABLE_TIMEOUT)
|
||||||
f"{self._url}/api_jsonrpc.php",
|
# any non-5xx answer means the web tier responded, so the server is
|
||||||
timeout=0.5
|
# up. Zabbix 7.0 returns 412 to a bare GET on api_jsonrpc.php (it
|
||||||
)
|
# wants a POST with json-rpc content type); that still counts.
|
||||||
reachable = response.status_code in (200, 401, 403, 405)
|
reachable = response.status_code < 500
|
||||||
except requests.RequestException:
|
except requests.RequestException:
|
||||||
reachable = False
|
reachable = False
|
||||||
|
|
||||||
cache.set(cache_key, reachable, timeout=self.REACHABLE_CHECK_TTL)
|
cache.set(cache_key, reachable, timeout=self.REACHABLE_CHECK_TTL)
|
||||||
logger.debug(f"Zabbix reachability check: {reachable}")
|
logger.debug("Zabbix reachability: %s", reachable)
|
||||||
return reachable
|
return reachable
|
||||||
|
|
||||||
def _apicall(self, method: str, params: Dict) -> Optional[Dict]:
|
# -- low level call ------------------------------------------------------
|
||||||
"""Make a Zabbix API call."""
|
|
||||||
|
def _apicall(self, method: str, params: Dict) -> Optional[object]:
|
||||||
|
"""One JSON-RPC call. Returns the result, or None on any error."""
|
||||||
if not self.isconfigured:
|
if not self.isconfigured:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
'jsonrpc': '2.0',
|
"jsonrpc": "2.0",
|
||||||
'method': method,
|
"method": method,
|
||||||
'params': params,
|
"params": params,
|
||||||
'auth': self._token,
|
"id": 1,
|
||||||
'id': 1
|
}
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json-rpc",
|
||||||
|
"Authorization": f"Bearer {self._token}",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{self._url}/api_jsonrpc.php",
|
self.endpoint,
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={'Content-Type': 'application/json'},
|
headers=headers,
|
||||||
timeout=0.5 # 500ms timeout - fail fast if Zabbix is slow/unreachable
|
timeout=self.API_TIMEOUT,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
except (requests.RequestException, ValueError) as exc:
|
||||||
if 'error' in data:
|
logger.error("Zabbix %s call failed: %s", method, exc)
|
||||||
logger.error(f"Zabbix API error: {data['error']}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return data.get('result')
|
|
||||||
|
|
||||||
except requests.RequestException as e:
|
|
||||||
logger.error(f"Zabbix API request failed: {e}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def gethostbyip(self, ip: str) -> Optional[Dict]:
|
if "error" in data:
|
||||||
"""Find a Zabbix host by IP address."""
|
logger.error("Zabbix %s error: %s", method, data["error"])
|
||||||
result = self._apicall('host.get', {
|
return None
|
||||||
'output': ['hostid', 'host', 'name'],
|
|
||||||
'filter': {'ip': ip},
|
|
||||||
'selectInterfaces': ['ip']
|
|
||||||
})
|
|
||||||
|
|
||||||
|
return data.get("result")
|
||||||
|
|
||||||
|
# -- host / item lookups -------------------------------------------------
|
||||||
|
|
||||||
|
def gethostidbyip(self, ip: str) -> Optional[str]:
|
||||||
|
"""Host id for a printer. Hosts are named by IP in this Zabbix."""
|
||||||
|
result = self._apicall("host.get", {
|
||||||
|
"output": ["hostid"],
|
||||||
|
"filter": {"host": [ip]},
|
||||||
|
})
|
||||||
if result:
|
if result:
|
||||||
return result[0] if result else None
|
return result[0].get("hostid")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]:
|
def _extract_color(self, item: Dict) -> str:
|
||||||
"""
|
"""Pull and normalise the color tag, falling back to the item name."""
|
||||||
Get printer supply levels by IP address.
|
color = ""
|
||||||
|
for tag in item.get("tags", []) or []:
|
||||||
|
if tag.get("tag") == "color":
|
||||||
|
color = (tag.get("value") or "").lower()
|
||||||
|
break
|
||||||
|
if "black" in color:
|
||||||
|
color = "black"
|
||||||
|
elif color in ("grey", "gray"):
|
||||||
|
color = "gray"
|
||||||
|
|
||||||
Returns list of supplies with name and level percentage.
|
if not color:
|
||||||
|
name = (item.get("name") or "").lower()
|
||||||
|
for candidate in ("cyan", "magenta", "yellow", "black"):
|
||||||
|
if candidate in name:
|
||||||
|
color = candidate
|
||||||
|
break
|
||||||
|
if not color and ("gray" in name or "grey" in name):
|
||||||
|
color = "gray"
|
||||||
|
return color
|
||||||
|
|
||||||
|
def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]:
|
||||||
|
"""Current supply levels for a printer, by IP.
|
||||||
|
|
||||||
|
Returns a list of dicts {name, level, color, itemid, status, state},
|
||||||
|
or None if the host is not in Zabbix. Drum/maintenance items are kept
|
||||||
|
(callers decide what to surface); only disabled (status=1) and
|
||||||
|
unsupported (state=1) items are dropped, matching the classic report.
|
||||||
"""
|
"""
|
||||||
# Find host by IP
|
hostid = self.gethostidbyip(ip)
|
||||||
host = self.gethostbyip(ip)
|
if not hostid:
|
||||||
if not host:
|
logger.debug("No Zabbix host for IP %s", ip)
|
||||||
logger.debug(f"No Zabbix host found for IP {ip}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
hostid = host['hostid']
|
items = self._apicall("item.get", {
|
||||||
|
"output": ["itemid", "name", "lastvalue", "lastclock",
|
||||||
# Get supply-related items
|
"units", "status", "state"],
|
||||||
items = self._apicall('item.get', {
|
"hostids": hostid,
|
||||||
'output': ['itemid', 'name', 'lastvalue', 'key_'],
|
"selectTags": "extend",
|
||||||
'hostids': hostid,
|
"evaltype": 0, # and
|
||||||
'search': {
|
"tags": self.SUPPLY_TAGS,
|
||||||
'key_': 'supply' # Common key pattern for printer supplies
|
"sortfield": "name",
|
||||||
},
|
"monitored": True,
|
||||||
'searchWildcardsEnabled': True
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if not items:
|
|
||||||
# Try alternate patterns
|
|
||||||
items = self._apicall('item.get', {
|
|
||||||
'output': ['itemid', 'name', 'lastvalue', 'key_'],
|
|
||||||
'hostids': hostid,
|
|
||||||
'search': {
|
|
||||||
'name': 'toner'
|
|
||||||
},
|
|
||||||
'searchWildcardsEnabled': True
|
|
||||||
})
|
|
||||||
|
|
||||||
if not items:
|
if not items:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
supplies = []
|
supplies = []
|
||||||
for item in items:
|
for item in items:
|
||||||
|
# skip disabled or unsupported items
|
||||||
|
if str(item.get("status", "0")) != "0":
|
||||||
|
continue
|
||||||
|
if str(item.get("state", "0")) != "0":
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
level = int(float(item.get('lastvalue', 0)))
|
level = int(float(item.get("lastvalue", 0)))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
level = 0
|
level = 0
|
||||||
|
|
||||||
supplies.append({
|
supplies.append({
|
||||||
'name': item.get('name', 'Unknown'),
|
"name": item.get("name", "Unknown"),
|
||||||
'level': level,
|
"level": level,
|
||||||
'itemid': item.get('itemid'),
|
"color": self._extract_color(item),
|
||||||
'key': item.get('key_'),
|
"itemid": item.get("itemid"),
|
||||||
})
|
})
|
||||||
|
|
||||||
return supplies
|
return supplies
|
||||||
|
|
||||||
def gethostid(self, ip: str) -> Optional[str]:
|
def getpingstatus(self, ip: str) -> str:
|
||||||
"""Get Zabbix host ID for an IP address."""
|
"""ICMP ping state for a printer: '1' up, '0' down, '-1' unknown."""
|
||||||
host = self.gethostbyip(ip)
|
hostid = self.gethostidbyip(ip)
|
||||||
return host['hostid'] if host else None
|
if not hostid:
|
||||||
|
return "-1"
|
||||||
|
items = self._apicall("item.get", {
|
||||||
|
"output": ["lastvalue"],
|
||||||
|
"hostids": hostid,
|
||||||
|
"search": {"key_": "icmpping"},
|
||||||
|
})
|
||||||
|
if items:
|
||||||
|
return str(items[0].get("lastvalue", "-1"))
|
||||||
|
return "-1"
|
||||||
|
|
||||||
|
# -- caching wrappers ----------------------------------------------------
|
||||||
|
|
||||||
def getsuppliesbyip_cached(self, ip: str) -> Optional[List[Dict]]:
|
def getsuppliesbyip_cached(self, ip: str) -> Optional[List[Dict]]:
|
||||||
"""Get printer supply levels with caching (10-minute TTL)."""
|
"""getsuppliesbyip with a 5-minute per-IP cache."""
|
||||||
cache_key = f'zabbix_supplies_{ip}'
|
cache_key = f"zabbix_supplies_{ip}"
|
||||||
result = cache.get(cache_key)
|
result = cache.get(cache_key)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
result = self.getsuppliesbyip(ip)
|
result = self.getsuppliesbyip(ip)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
cache.set(cache_key, result, timeout=self.CACHE_TTL)
|
cache.set(cache_key, result, timeout=self.CACHE_TTL)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def clearcache(self, ip: str = None):
|
def clearcache(self, ip: str = None):
|
||||||
"""Clear cached supply data for one IP or all."""
|
"""Drop cached supply data for one IP, plus the low-supplies roll-up."""
|
||||||
if ip:
|
if ip:
|
||||||
cache.delete(f'zabbix_supplies_{ip}')
|
cache.delete(f"zabbix_supplies_{ip}")
|
||||||
cache.delete('printers_low_supplies')
|
cache.delete("printers_low_supplies")
|
||||||
|
cache.delete("zabbix_reachable")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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"}
|
|
||||||
@@ -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')
|
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user