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=
|
||||
# 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 |
|
||||
| `LOG_LEVEL` | No | Default INFO |
|
||||
| `ZABBIX_URL`, `ZABBIX_TOKEN` | No | Only if printers plugin uses Zabbix |
|
||||
| `COLLECTOR_API_KEY` | No | Shared key for `/api/collector/*` ingest. Required only if unattended collectors push data. Endpoint fails closed (denies) when unset. |
|
||||
| `COLLECTOR_API_KEY_<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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
|
||||
The framework declares its contract version in `shopdb/__init__.py`:
|
||||
|
||||
```python
|
||||
__contract_version__ = '0.2.0'
|
||||
__contract_version__ = '0.4.0'
|
||||
```
|
||||
|
||||
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
||||
@@ -140,7 +140,9 @@ class ComputersPlugin(BasePlugin):
|
||||
|
||||
### `get_services() -> Dict[str, Type]`
|
||||
|
||||
Returns a dict of service-name to service-class. Other plugins can request services via the plugin manager.
|
||||
Returns a dict of service-name to service-class. Another plugin obtains one via
|
||||
`plugin_manager.get_service('<name>')`, which searches enabled plugins and
|
||||
returns the registered class/factory (or None).
|
||||
|
||||
```python
|
||||
from .services import ZabbixService
|
||||
@@ -166,6 +168,10 @@ class NotificationsPlugin(BasePlugin):
|
||||
}]
|
||||
```
|
||||
|
||||
Consumed by `GET /api/dashboard/widgets`, which merges widgets from all enabled
|
||||
plugins sorted by `position` (disabled plugins are skipped; a broken plugin is
|
||||
isolated in prod, re-raised in dev/test).
|
||||
|
||||
### `get_navigation_items() -> List[Dict]`
|
||||
|
||||
Returns navigation menu items.
|
||||
@@ -181,24 +187,10 @@ class ComputersPlugin(BasePlugin):
|
||||
}]
|
||||
```
|
||||
|
||||
### `get_searchable_fields() -> List[Dict]`
|
||||
|
||||
Declares fields the plugin contributes to global search.
|
||||
|
||||
```python
|
||||
from .models import Computer
|
||||
|
||||
class ComputersPlugin(BasePlugin):
|
||||
def get_searchable_fields(self):
|
||||
return [{
|
||||
'model': Computer,
|
||||
'search_fields': ['hostname', 'serialnumber', 'currentuser'],
|
||||
'result_type': 'computer',
|
||||
'url_template': '/computers/{id}',
|
||||
'title_field': 'hostname',
|
||||
'subtitle_field': 'currentuser',
|
||||
}]
|
||||
```
|
||||
> Removed in contract 0.4.0: `get_searchable_fields`. Global search
|
||||
> (`/api/search`) is a core concern that queries the asset model directly and
|
||||
> already covers every bundled asset type; no plugin ever implemented the hook.
|
||||
> Search honors runtime plugin enable/disable.
|
||||
|
||||
### `get_collector_schema() -> Optional[Dict]`
|
||||
|
||||
@@ -222,6 +214,31 @@ class ComputersPlugin(BasePlugin):
|
||||
|
||||
If the hook returns `None` (the default), no collector endpoint is registered.
|
||||
|
||||
### `apply_collector_payload(payload: Dict) -> Dict`
|
||||
|
||||
Companion to `get_collector_schema` (ADR-006). The generic
|
||||
`/api/collector/<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
|
||||
|
||||
These run when the plugin's installation state changes. All optional.
|
||||
@@ -233,6 +250,34 @@ These run when the plugin's installation state changes. All optional.
|
||||
| `on_enable(app)` | When the plugin is enabled at runtime | Subscribe to events, warm caches |
|
||||
| `on_disable(app)` | When the plugin is disabled at runtime | Unsubscribe, drain queues |
|
||||
|
||||
## The import surface (`shopdb.api`)
|
||||
|
||||
`shopdb.api` is the ONLY core module a plugin may import from (besides
|
||||
`shopdb.plugins.base` for `BasePlugin` / `PluginMeta`). Importing internal
|
||||
paths like `shopdb.core.models.*`, `shopdb.extensions`, or `shopdb.utils.*`
|
||||
is a contract violation and fails the test
|
||||
`tests/test_plugin_contract.py::test_plugins_only_import_contract_surface`.
|
||||
|
||||
What `shopdb.api` exposes:
|
||||
|
||||
- Infrastructure: `db`, `cache`
|
||||
- Model bases: `BaseModel`, `AuditMixin`
|
||||
- Core models: `Asset`, `AssetType`, `AssetStatus`, `Vendor`, `Model`,
|
||||
`Communication`, `CommunicationType`, `Location`, `Setting`, `AuditLog`,
|
||||
`Application`, `AppVersion`, `OperatingSystem`
|
||||
- Responses: `success_response`, `error_response`, `paginated_response`,
|
||||
`ErrorCodes`
|
||||
- Pagination: `get_pagination_params`, `paginate_query`
|
||||
- Helpers: `audit_log`, `resolve_asset_position`
|
||||
- Legacy employee directory: `employee_connection`
|
||||
|
||||
```python
|
||||
from shopdb.api import db, Asset, AssetType, success_response, paginate_query
|
||||
```
|
||||
|
||||
Adding a name to `shopdb.api` is an additive (minor) contract bump; removing
|
||||
one is breaking (major). See ADR-002.
|
||||
|
||||
## Helpers exposed to plugins
|
||||
|
||||
The framework provides helper APIs in `shopdb.api` (the public namespace).
|
||||
|
||||
@@ -119,10 +119,9 @@ Override hooks on the plugin class as needed. See [PLUGIN-HOOKS.md](PLUGIN-HOOKS
|
||||
|
||||
| Hook | Adds |
|
||||
|------|------|
|
||||
| `get_searchable_fields` | Plugin contributes to the global search endpoint |
|
||||
| `get_navigation_items` | Plugin shows up in the sidebar nav |
|
||||
| `get_dashboard_widgets` | Plugin's dashboard widget appears on the home page |
|
||||
| `get_collector_schema` | Plugin accepts external pushes at `/api/collector/<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.
|
||||
|
||||
|
||||
@@ -36,10 +36,29 @@ The following are the public, versioned surface. Plugin authors may depend on th
|
||||
|
||||
- `AuditLog` API: `audit_log(action, entitytype, entityid, ...)` for plugins to record audit entries with consistent schema
|
||||
- `Setting` API: `plugin.get_setting(key)` and `plugin.set_setting(key, value)` for plugin-scoped config persisted via the core `Setting` model
|
||||
- `resolve_asset_position(asset)` for the documented position-resolution algorithm
|
||||
|
||||
#### Import surface (`shopdb.api`) - expanded in __contract_version__ 0.3.0
|
||||
|
||||
`shopdb.api` is the ONLY core module plugins may import (plus `shopdb.plugins.base`
|
||||
for the ABC). Deep imports of `shopdb.core.*`, `shopdb.extensions`, or
|
||||
`shopdb.utils.*` are contract violations enforced by the
|
||||
`test_plugins_only_import_contract_surface` test. The surface re-exports:
|
||||
|
||||
- Infrastructure: `db`, `cache`
|
||||
- Model bases: `BaseModel`, `AuditMixin`
|
||||
- Core models: `Asset`, `AssetType`, `AssetStatus`, `Vendor`, `Model`,
|
||||
`Communication`, `CommunicationType`, `Location`, `Setting`, `AuditLog`,
|
||||
`Application`, `AppVersion`, `OperatingSystem`
|
||||
- Responses: `success_response`, `error_response`, `paginated_response`, `ErrorCodes`
|
||||
- Pagination: `get_pagination_params`, `paginate_query`
|
||||
- Helpers: `audit_log`, `resolve_asset_position`; legacy `employee_connection`
|
||||
|
||||
Adding a name here is a minor (additive) contract change; removing one is major.
|
||||
|
||||
#### Plugin contract
|
||||
|
||||
- `BasePlugin` ABC and its hooks (search, navigation, dashboard, relationships, collector schema)
|
||||
- `BasePlugin` ABC and its hooks (navigation, dashboard widgets, collector schema + `apply_collector_payload`). Note: `get_searchable_fields` was removed in contract 0.4.0 - global search is a core concern over the asset model, not a per-plugin hook.
|
||||
|
||||
#### Excluded from the contract for v1
|
||||
|
||||
|
||||
@@ -56,6 +56,19 @@ The framework provides:
|
||||
1. **Multi-tenant single instance.** Lower operational overhead at scale, easier cross-site reporting, but adds significant code complexity and risk: every query needs a tenant filter, auth gets complex, schema migrations affect every site at once, and a bug at one site can leak data across sites. Rejected for v1; revisit if and only if more than five sites adopt and operational overhead becomes painful.
|
||||
2. **Hybrid: per-site DB but central app server.** Adds the operational complexity of multi-tenancy without isolating the failure domain (one app crash = all sites down). Rejected.
|
||||
|
||||
## Migration strategy (resolved)
|
||||
|
||||
Deploys run a single core Alembic chain: `flask db upgrade`. Bundled plugins do
|
||||
NOT carry their own migration chains - their tables are folded into the core
|
||||
chain (migration `7c04_fold_plugin_schema`). This was a deliberate resolution of
|
||||
the Phase 7B footgun where bundled-plugin baselines and the core baseline both
|
||||
created the same tables, so `flask plugin upgrade-all` would conflict. A fresh
|
||||
`flask db upgrade` reproduces the live schema exactly (verified on a scratch DB).
|
||||
|
||||
External (out-of-tree) plugins per ADR-003 may still ship their own migrations;
|
||||
the framework supports per-plugin chains for them. Only the in-tree bundled
|
||||
plugins are consolidated into core.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should the framework provide an optional **read-only fleet roll-up** mode where a "central" instance can pull aggregate metrics from each site's API? Defer. Out of scope for v1.
|
||||
|
||||
@@ -15,7 +15,7 @@ This calls for a generalizable contract: any plugin that wants to accept externa
|
||||
|
||||
## Decision
|
||||
|
||||
`BasePlugin` gets one new optional hook:
|
||||
`BasePlugin` gets two new hooks (added in __contract_version__ 0.2.x -> the surface is carried at 0.3.0):
|
||||
|
||||
```python
|
||||
def get_collector_schema(self) -> Optional[dict]:
|
||||
@@ -29,9 +29,26 @@ def get_collector_schema(self) -> Optional[dict]:
|
||||
- 'fields': JSON Schema definitions for the rest of the payload.
|
||||
"""
|
||||
return None
|
||||
|
||||
def apply_collector_payload(self, payload: dict) -> dict:
|
||||
"""Idempotently upsert an asset from a validated collector payload.
|
||||
|
||||
Called by /api/collector/<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` (fallback, shared)
|
||||
@@ -125,7 +142,7 @@ Migration path:
|
||||
## References
|
||||
|
||||
- `shopdb/core/api/collector.py` (legacy endpoint to be removed)
|
||||
- `shopdb/plugins/base.py` (`get_collector_schema` hook to be added)
|
||||
- `shopdb/plugins/base.py` (`get_collector_schema` + `apply_collector_payload` hooks)
|
||||
- ADR-001 (asset model the collectors target)
|
||||
- ADR-002 (collector schema is part of plugin contract; changes to the hook signature are major bumps)
|
||||
- The PXE project (`/home/camp/projects/pxe/`) which feeds the computers collector
|
||||
|
||||
@@ -58,38 +58,6 @@ export const authApi = {
|
||||
}
|
||||
}
|
||||
|
||||
// Machines API (legacy - use equipmentApi or computersApi instead)
|
||||
export const machinesApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/machines', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/machines/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/machines', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/machines/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/machines/${id}`)
|
||||
},
|
||||
updateCommunication(id, data) {
|
||||
return api.put(`/machines/${id}/communication`, data)
|
||||
},
|
||||
// Relationships
|
||||
getRelationships(id) {
|
||||
return api.get(`/machines/${id}/relationships`)
|
||||
},
|
||||
createRelationship(id, data) {
|
||||
return api.post(`/machines/${id}/relationships`, data)
|
||||
},
|
||||
deleteRelationship(relationshipId) {
|
||||
return api.delete(`/machines/relationships/${relationshipId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Equipment API (plugin)
|
||||
export const equipmentApi = {
|
||||
list(params = {}) {
|
||||
@@ -176,10 +144,10 @@ export const computersApi = {
|
||||
// Relationship Types API
|
||||
export const relationshipTypesApi = {
|
||||
list() {
|
||||
return api.get('/machines/relationshiptypes')
|
||||
return api.get('/assets/relationshiptypes')
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/machines/relationshiptypes', data)
|
||||
return api.post('/assets/relationshiptypes', data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,25 +167,6 @@ export const machinetypesApi = {
|
||||
}
|
||||
}
|
||||
|
||||
// Statuses API
|
||||
export const statusesApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/statuses', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/statuses/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/statuses', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/statuses/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/statuses/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Vendors API
|
||||
export const vendorsApi = {
|
||||
list(params = {}) {
|
||||
@@ -253,6 +202,11 @@ export const locationsApi = {
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/locations/${id}`)
|
||||
},
|
||||
types: {
|
||||
list() {
|
||||
return api.get('/locations/types')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,9 +218,26 @@ export const printersApi = {
|
||||
get(id) {
|
||||
return api.get(`/printers/${id}`)
|
||||
},
|
||||
// create/update write asset core + printer extension in one call (the
|
||||
// printers plugin owns both). Use these instead of the legacy machinesApi.
|
||||
create(data) {
|
||||
return api.post('/printers', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/printers/${id}`, data)
|
||||
},
|
||||
updateExtension(id, data) {
|
||||
return api.put(`/printers/${id}/printerdata`, data)
|
||||
},
|
||||
// printer sub-types (Laser, Inkjet, Label, Card, Wide Format, ...)
|
||||
types: {
|
||||
list(params = {}) {
|
||||
return api.get('/printers/types', { params })
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/printers/types', data)
|
||||
}
|
||||
},
|
||||
updateCommunication(id, data) {
|
||||
return api.put(`/printers/${id}/communication`, data)
|
||||
},
|
||||
@@ -279,6 +250,12 @@ export const printersApi = {
|
||||
lowSupplies() {
|
||||
return api.get('/printers/lowsupplies')
|
||||
},
|
||||
refreshSupplies() {
|
||||
return api.post('/printers/supplies/refresh')
|
||||
},
|
||||
lookup({ ip, fqdn } = {}) {
|
||||
return api.get('/printers/lookup', { params: { ip, fqdn } })
|
||||
},
|
||||
dashboardSummary() {
|
||||
return api.get('/printers/dashboard/summary')
|
||||
},
|
||||
@@ -303,6 +280,27 @@ export const printersApi = {
|
||||
create(data) {
|
||||
return api.post('/printers/supplytypes', data)
|
||||
}
|
||||
},
|
||||
// model -> toner/drum/waste part-number management
|
||||
modelSupplies: {
|
||||
meta() {
|
||||
return api.get('/printers/supplies/meta')
|
||||
},
|
||||
listModels(params = {}) {
|
||||
return api.get('/printers/models', { params })
|
||||
},
|
||||
list(modelnumberid) {
|
||||
return api.get(`/printers/models/${modelnumberid}/supplies`)
|
||||
},
|
||||
create(modelnumberid, data) {
|
||||
return api.post(`/printers/models/${modelnumberid}/supplies`, data)
|
||||
},
|
||||
update(modelsupplyid, data) {
|
||||
return api.put(`/printers/supplies/${modelsupplyid}`, data)
|
||||
},
|
||||
delete(modelsupplyid) {
|
||||
return api.delete(`/printers/supplies/${modelsupplyid}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +319,23 @@ export const modelsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/models', { params })
|
||||
},
|
||||
// Backend caps perpage at 100, so page through every model. Returns the
|
||||
// full array directly (not an axios response). Use in forms whose model
|
||||
// dropdown must include the editing record's model regardless of page.
|
||||
async listAll() {
|
||||
const first = await api.get('/models', { params: { perpage: 100, page: 1 } })
|
||||
let items = first.data.data || []
|
||||
const totalpages = first.data.meta?.pagination?.totalpages || 1
|
||||
if (totalpages > 1) {
|
||||
const rest = await Promise.all(
|
||||
Array.from({ length: totalpages - 1 }, (_, i) =>
|
||||
api.get('/models', { params: { perpage: 100, page: i + 2 } })
|
||||
)
|
||||
)
|
||||
rest.forEach(r => { items = items.concat(r.data.data || []) })
|
||||
}
|
||||
return items
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/models/${id}`)
|
||||
},
|
||||
@@ -335,25 +350,6 @@ export const modelsApi = {
|
||||
}
|
||||
}
|
||||
|
||||
// PC Types API
|
||||
export const pctypesApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/pctypes', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/pctypes/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/pctypes', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/pctypes/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/pctypes/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Operating Systems API
|
||||
export const operatingsystemsApi = {
|
||||
list(params = {}) {
|
||||
@@ -528,8 +524,17 @@ export const assetsApi = {
|
||||
}
|
||||
},
|
||||
statuses: {
|
||||
list() {
|
||||
return api.get('/assets/statuses')
|
||||
list(params = {}) {
|
||||
return api.get('/assets/statuses', { params })
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/assets/statuses', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/assets/statuses/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/assets/statuses/${id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -669,6 +674,15 @@ export const employeesApi = {
|
||||
export const businessUnitsApi = businessunitsApi
|
||||
|
||||
// System Settings API
|
||||
export const pluginsApi = {
|
||||
list() {
|
||||
return api.get('/plugins')
|
||||
},
|
||||
setEnabled(name, enabled) {
|
||||
return api.put(`/plugins/${name}`, { enabled })
|
||||
}
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/settings', { params })
|
||||
|
||||
@@ -372,6 +372,15 @@ th, td {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Cap a long free-text column (e.g. Description) so it truncates with an
|
||||
ellipsis instead of widening the row and pushing the Actions column out of
|
||||
view. Pair with a title attribute to show the full text on hover. */
|
||||
td.cell-truncate {
|
||||
max-width: 32rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
|
||||
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'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/plugins',
|
||||
name: 'plugins',
|
||||
component: () => import('../../views/settings/PluginsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/auditlogs',
|
||||
name: 'audit-logs',
|
||||
|
||||
@@ -23,5 +23,12 @@ export default [
|
||||
name: 'printer-edit',
|
||||
component: () => import('../../views/printers/PrinterForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
// printer-specific settings
|
||||
{
|
||||
path: 'settings/modelsupplies',
|
||||
name: 'model-supplies',
|
||||
component: () => import('../../views/settings/ModelSuppliesList.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -65,18 +65,18 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Category</th>
|
||||
<th>Asset #</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Business Unit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="machine in recentMachines" :key="machine.machineid">
|
||||
<td>{{ machine.machinenumber || machine.hostname || machine.alias || '-' }}</td>
|
||||
<td>{{ machine.category || '-' }}</td>
|
||||
<td>{{ machine.machinetype || '-' }}</td>
|
||||
<td>{{ machine.businessunit || '-' }}</td>
|
||||
<tr v-for="machine in recentMachines" :key="machine.assetid">
|
||||
<td>{{ machine.assetnumber || machine.name || '-' }}</td>
|
||||
<td>{{ machine.assettypename || '-' }}</td>
|
||||
<td>{{ machine.statusname || '-' }}</td>
|
||||
<td>{{ machine.businessunitname || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="recentMachines.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { dashboardApi, machinesApi, printersApi } from '../api'
|
||||
import { dashboardApi, assetsApi, printersApi } from '../api'
|
||||
|
||||
const loading = ref(true)
|
||||
const stats = ref({})
|
||||
@@ -104,7 +104,7 @@ onMounted(async () => {
|
||||
try {
|
||||
const [dashRes, machinesRes, printersRes] = await Promise.all([
|
||||
dashboardApi.summary().catch(() => ({ data: { data: {} } })),
|
||||
machinesApi.list({ perpage: 5 }).catch(() => ({ data: { data: [] } })),
|
||||
assetsApi.list({ perpage: 5 }).catch(() => ({ data: { data: [] } })),
|
||||
printersApi.dashboardSummary().catch(() => ({ data: { data: {} } }))
|
||||
])
|
||||
|
||||
|
||||
@@ -100,12 +100,12 @@
|
||||
<router-link
|
||||
v-for="install in installedOn"
|
||||
:key="install.id"
|
||||
:to="`/pcs/${install.machineid}`"
|
||||
:to="`/pcs/${install.computerid}`"
|
||||
class="pc-item"
|
||||
>
|
||||
<div class="pc-info">
|
||||
<span class="pc-name">{{ install.machine?.machinenumber || `PC #${install.machineid}` }}</span>
|
||||
<span class="pc-alias" v-if="install.machine?.alias">{{ install.machine.alias }}</span>
|
||||
<span class="pc-name">{{ install.computer?.hostname || install.computer?.assetnumber || `PC #${install.computerid}` }}</span>
|
||||
<span class="pc-alias" v-if="install.computer?.assetnumber">{{ install.computer.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="pc-version" v-if="install.version">
|
||||
v{{ install.version }}
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
<input type="checkbox" v-model="form.isprinter" />
|
||||
Printer App
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.isrequired" />
|
||||
Required on all PCs
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.ishidden" />
|
||||
Hidden
|
||||
@@ -168,6 +172,7 @@ const form = ref({
|
||||
isinstallable: false,
|
||||
islicenced: false,
|
||||
isprinter: false,
|
||||
isrequired: false,
|
||||
ishidden: false,
|
||||
applicationlink: '',
|
||||
documentationpath: '',
|
||||
@@ -196,6 +201,7 @@ onMounted(async () => {
|
||||
isinstallable: app.isinstallable || false,
|
||||
islicenced: app.islicenced || false,
|
||||
isprinter: app.isprinter || false,
|
||||
isrequired: app.isrequired || false,
|
||||
ishidden: app.ishidden || false,
|
||||
applicationlink: app.applicationlink || '',
|
||||
documentationpath: app.documentationpath || '',
|
||||
@@ -224,6 +230,7 @@ async function saveApplication() {
|
||||
isinstallable: form.value.isinstallable,
|
||||
islicenced: form.value.islicenced,
|
||||
isprinter: form.value.isprinter,
|
||||
isrequired: form.value.isrequired,
|
||||
ishidden: form.value.ishidden,
|
||||
applicationlink: form.value.applicationlink || null,
|
||||
documentationpath: form.value.documentationpath || null,
|
||||
|
||||
@@ -68,6 +68,14 @@
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ equipment.name }}</span>
|
||||
</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">
|
||||
<span class="info-label">Serial Number</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 { equipmentApi, assetsApi } from '../../api'
|
||||
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
|
||||
const route = useRoute()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const equipment = ref(null)
|
||||
|
||||
@@ -30,6 +30,31 @@
|
||||
type="text"
|
||||
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>
|
||||
|
||||
@@ -331,6 +356,9 @@ import { equipmentApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, co
|
||||
import ShopFloorMap from '../../components/ShopFloorMap.vue'
|
||||
import Modal from '../../components/Modal.vue'
|
||||
import { currentTheme } from '../../stores/theme'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -346,6 +374,8 @@ const tempMapPosition = ref(null)
|
||||
const form = ref({
|
||||
assetnumber: '',
|
||||
name: '',
|
||||
gaugelabreference: '',
|
||||
maintenancereference: '',
|
||||
serialnumber: '',
|
||||
statusid: 1,
|
||||
equipmenttypeid: '',
|
||||
@@ -410,12 +440,12 @@ watch(() => form.value.controllervendorid, (newVal, oldVal) => {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 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(),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.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 }),
|
||||
computersApi.list({ perpage: 500 }),
|
||||
assetsApi.types.list() // Used for relationship types, will fix below
|
||||
@@ -425,7 +455,7 @@ onMounted(async () => {
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
locations.value = locRes.data.data || []
|
||||
models.value = modelsRes.data.data || []
|
||||
models.value = allModels
|
||||
businessunits.value = buRes.data.data || []
|
||||
pcs.value = pcsRes.data.data || []
|
||||
|
||||
@@ -456,6 +486,8 @@ onMounted(async () => {
|
||||
form.value = {
|
||||
assetnumber: data.assetnumber || '',
|
||||
name: data.name || '',
|
||||
gaugelabreference: data.gaugelabreference || '',
|
||||
maintenancereference: data.maintenancereference || '',
|
||||
serialnumber: data.serialnumber || '',
|
||||
statusid: data.statusid || 1,
|
||||
equipmenttypeid: data.equipment?.equipmenttypeid || '',
|
||||
@@ -526,6 +558,8 @@ async function saveEquipment() {
|
||||
const data = {
|
||||
assetnumber: form.value.assetnumber,
|
||||
name: form.value.name || null,
|
||||
gaugelabreference: form.value.gaugelabreference || null,
|
||||
maintenancereference: form.value.maintenancereference || null,
|
||||
serialnumber: form.value.serialnumber || null,
|
||||
statusid: form.value.statusid || 1,
|
||||
equipmenttypeid: form.value.equipmenttypeid || null,
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset #</th>
|
||||
<th>Machine #</th>
|
||||
<th>Name</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
|
||||
@@ -111,6 +111,14 @@
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ device.serialnumber || '-' }}</span>
|
||||
</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">
|
||||
<span class="info-label">Vendor</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 { networkApi } from '../../api'
|
||||
import AssetRelationships from '../../components/AssetRelationships.vue'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -54,6 +54,27 @@
|
||||
</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-group">
|
||||
<label for="locationid">Location</label>
|
||||
@@ -226,9 +247,12 @@ import {
|
||||
networkApi,
|
||||
vendorsApi,
|
||||
locationsApi,
|
||||
statusesApi,
|
||||
assetsApi,
|
||||
businessunitsApi
|
||||
} from '../../api'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -240,6 +264,8 @@ const form = ref({
|
||||
assetnumber: '',
|
||||
name: '',
|
||||
serialnumber: '',
|
||||
gaugelabreference: '',
|
||||
maintenancereference: '',
|
||||
statusid: '',
|
||||
locationid: '',
|
||||
businessunitid: '',
|
||||
@@ -307,7 +333,7 @@ async function loadLocations() {
|
||||
|
||||
async function loadStatuses() {
|
||||
try {
|
||||
const response = await statusesApi.list({ perpage: 100 })
|
||||
const response = await assetsApi.statuses.list()
|
||||
statuses.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading statuses:', err)
|
||||
@@ -332,6 +358,8 @@ async function loadDevice() {
|
||||
form.value.assetnumber = data.assetnumber || ''
|
||||
form.value.name = data.name || ''
|
||||
form.value.serialnumber = data.serialnumber || ''
|
||||
form.value.gaugelabreference = data.gaugelabreference || ''
|
||||
form.value.maintenancereference = data.maintenancereference || ''
|
||||
form.value.statusid = data.statusid || ''
|
||||
form.value.locationid = data.locationid || ''
|
||||
form.value.businessunitid = data.businessunitid || ''
|
||||
@@ -365,6 +393,8 @@ async function submitForm() {
|
||||
assetnumber: form.value.assetnumber,
|
||||
name: form.value.name || null,
|
||||
serialnumber: form.value.serialnumber || null,
|
||||
gaugelabreference: form.value.gaugelabreference || null,
|
||||
maintenancereference: form.value.maintenancereference || null,
|
||||
statusid: form.value.statusid || null,
|
||||
locationid: form.value.locationid || null,
|
||||
businessunitid: form.value.businessunitid || null,
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset #</th>
|
||||
<th>Asset Tag</th>
|
||||
<th>Hostname</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<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 class="hero-meta">
|
||||
<span class="badge badge-lg badge-info">Computer</span>
|
||||
@@ -57,7 +57,7 @@
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ computer.name }}</span>
|
||||
</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-value mono">{{ computer.computer.hostname }}</span>
|
||||
</div>
|
||||
@@ -65,6 +65,14 @@
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ computer.serialnumber }}</span>
|
||||
</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>
|
||||
|
||||
@@ -207,8 +215,10 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computersApi, applicationsApi, assetsApi } from '../../api'
|
||||
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
|
||||
const route = useRoute()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const computer = ref(null)
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
@input="onPcNumberInput"
|
||||
/>
|
||||
<small class="form-hint">Defaults to the serial number; editable</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -32,7 +34,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<div class="form-group" v-if="isEnabled('fqdn', 'computer')">
|
||||
<label for="hostname">Hostname</label>
|
||||
<input
|
||||
id="hostname"
|
||||
@@ -53,6 +55,28 @@
|
||||
</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-group">
|
||||
<label for="machinetypeid">PC Type *</label>
|
||||
@@ -66,10 +90,10 @@
|
||||
<option value="">Select type...</option>
|
||||
<option
|
||||
v-for="pt in pcTypes"
|
||||
:key="pt.machinetypeid"
|
||||
:value="pt.machinetypeid"
|
||||
:key="pt.computertypeid"
|
||||
:value="pt.computertypeid"
|
||||
>
|
||||
{{ pt.machinetype }}
|
||||
{{ pt.computertype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -266,18 +290,28 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
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 Modal from '../../components/Modal.vue'
|
||||
import { currentTheme } from '../../stores/theme'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
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 saving = ref(false)
|
||||
const error = ref('')
|
||||
@@ -289,6 +323,8 @@ const form = ref({
|
||||
alias: '',
|
||||
hostname: '',
|
||||
serialnumber: '',
|
||||
gaugelabreference: '',
|
||||
maintenancereference: '',
|
||||
machinetypeid: '',
|
||||
statusid: '',
|
||||
vendorid: '',
|
||||
@@ -311,60 +347,65 @@ const models = ref([])
|
||||
const locations = 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
|
||||
const filteredModels = computed(() => {
|
||||
return models.value.filter(m => {
|
||||
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
|
||||
return false
|
||||
}
|
||||
if (form.value.machinetypeid && m.machinetypeid !== form.value.machinetypeid) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
// filter by vendor only (PC type now maps to computertypeid, a different id
|
||||
// space than a model's machinetypeid)
|
||||
if (!form.value.vendorid) return models.value
|
||||
return models.value.filter(m => m.vendorid === form.value.vendorid)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load reference data
|
||||
const [ptRes, statusRes, vendorRes, modelsRes, locRes, osRes] = await Promise.all([
|
||||
machinetypesApi.list({ category: 'PC' }),
|
||||
statusesApi.list(),
|
||||
vendorsApi.list(),
|
||||
modelsApi.list(),
|
||||
locationsApi.list(),
|
||||
operatingsystemsApi.list()
|
||||
// perpage 100 so dropdowns aren't truncated to the default 20-row page
|
||||
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes] = await Promise.all([
|
||||
computersApi.types.list({ perpage: 100 }),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 100 }),
|
||||
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
locationsApi.list({ perpage: 100 }),
|
||||
operatingsystemsApi.list({ perpage: 100 })
|
||||
])
|
||||
|
||||
pcTypes.value = ptRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
models.value = modelsRes.data.data || []
|
||||
models.value = allModels
|
||||
locations.value = locRes.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) {
|
||||
const response = await machinesApi.get(route.params.id)
|
||||
const response = await computersApi.get(route.params.id)
|
||||
const pc = response.data.data
|
||||
const ext = pc.computer || {}
|
||||
|
||||
// Get IP from communications
|
||||
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
|
||||
|
||||
form.value = {
|
||||
machinenumber: pc.machinenumber || '',
|
||||
alias: pc.alias || '',
|
||||
hostname: pc.hostname || '',
|
||||
machinenumber: pc.assetnumber || '',
|
||||
alias: pc.name && pc.name.toUpperCase() !== 'NONE' ? pc.name : '',
|
||||
hostname: ext.hostname || '',
|
||||
serialnumber: pc.serialnumber || '',
|
||||
machinetypeid: pc.machinetype?.machinetypeid || '',
|
||||
statusid: pc.status?.statusid || '',
|
||||
vendorid: pc.vendor?.vendorid || '',
|
||||
modelnumberid: pc.model?.modelnumberid || '',
|
||||
locationid: pc.location?.locationid || '',
|
||||
osid: pc.operatingsystem?.osid || '',
|
||||
loggedinuser: pc.loggedinuser || '',
|
||||
isvnc: pc.isvnc || false,
|
||||
iswinrm: pc.iswinrm || false,
|
||||
gaugelabreference: pc.gaugelabreference || '',
|
||||
maintenancereference: pc.maintenancereference || '',
|
||||
machinetypeid: ext.computertypeid || '',
|
||||
statusid: pc.statusid || '',
|
||||
vendorid: ext.vendorid || '',
|
||||
modelnumberid: ext.modelnumberid || '',
|
||||
locationid: pc.locationid || '',
|
||||
osid: ext.osid || '',
|
||||
loggedinuser: ext.loggedinuser || '',
|
||||
isvnc: ext.isvnc || false,
|
||||
iswinrm: ext.iswinrm || false,
|
||||
notes: pc.notes || '',
|
||||
mapx: pc.mapx ?? null,
|
||||
mapy: pc.mapy ?? null,
|
||||
@@ -402,40 +443,37 @@ async function savePC() {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const machineData = {
|
||||
machinenumber: form.value.machinenumber,
|
||||
alias: form.value.alias,
|
||||
hostname: form.value.hostname,
|
||||
serialnumber: form.value.serialnumber,
|
||||
machinetypeid: form.value.machinetypeid || null,
|
||||
// One payload for the computers plugin (asset core + computer extension +
|
||||
// primary IP). "PC Number" is the business identifier (assetnumber).
|
||||
const payload = {
|
||||
assetnumber: form.value.machinenumber,
|
||||
hostname: form.value.hostname || 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,
|
||||
vendorid: form.value.vendorid || null,
|
||||
modelnumberid: form.value.modelnumberid || null,
|
||||
locationid: form.value.locationid || null,
|
||||
osid: form.value.osid || null,
|
||||
loggedinuser: form.value.loggedinuser,
|
||||
loggedinuser: form.value.loggedinuser || null,
|
||||
isvnc: form.value.isvnc,
|
||||
iswinrm: form.value.iswinrm,
|
||||
notes: form.value.notes,
|
||||
notes: form.value.notes || null,
|
||||
ipaddress: form.value.ipaddress || null,
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy
|
||||
}
|
||||
|
||||
let machineId
|
||||
if (isEdit.value) {
|
||||
await machinesApi.update(route.params.id, machineData)
|
||||
machineId = route.params.id
|
||||
} else {
|
||||
const response = await machinesApi.create(machineData)
|
||||
machineId = response.data.data.machineid
|
||||
// only set display name when an alias is given, so we don't clobber it
|
||||
if (form.value.alias) {
|
||||
payload.name = form.value.alias
|
||||
}
|
||||
|
||||
// Handle IP address - update communication record
|
||||
if (form.value.ipaddress) {
|
||||
await machinesApi.updateCommunication(machineId, {
|
||||
ipaddress: form.value.ipaddress,
|
||||
isprimary: true
|
||||
})
|
||||
if (isEdit.value) {
|
||||
await computersApi.update(route.params.id, payload)
|
||||
} else {
|
||||
await computersApi.create(payload)
|
||||
}
|
||||
|
||||
router.push('/pcs')
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset #</th>
|
||||
<th>Asset Tag</th>
|
||||
<th>Hostname</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
@@ -153,13 +153,15 @@ function getStatusClass(status) {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
margin-right: 0.375rem;
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 5px;
|
||||
@@ -167,6 +169,17 @@ function getStatusClass(status) {
|
||||
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 {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<template v-if="page[pos - 1]">
|
||||
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
|
||||
<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 class="info-section">
|
||||
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
|
||||
@@ -65,12 +65,14 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { printersApi } from '../../api'
|
||||
import QRCode from 'qrcode'
|
||||
import { renderQrDataUrl } from './qrLogo'
|
||||
|
||||
const printers = ref([])
|
||||
const selectedPrinters = ref([])
|
||||
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)
|
||||
|
||||
@@ -102,52 +104,19 @@ watch(selectedPrinters, async () => {
|
||||
generateQRCodes()
|
||||
}, { deep: true })
|
||||
|
||||
function setQrRef(el, pageIdx, pos) {
|
||||
if (el) {
|
||||
qrRefs.value[`${pageIdx}-${pos}`] = el
|
||||
}
|
||||
}
|
||||
|
||||
function generateQRCodes() {
|
||||
pages.value.forEach((page, pageIdx) => {
|
||||
page.forEach((printer, idx) => {
|
||||
if (!printer) return
|
||||
async function generateQRCodes() {
|
||||
const next = {}
|
||||
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]
|
||||
if (!printer) continue
|
||||
const pos = idx + 1
|
||||
const canvas = qrRefs.value[`${pageIdx}-${pos}`]
|
||||
if (!canvas) return
|
||||
|
||||
const qrUrl = `${window.location.origin}/printers/${printer.printer?.printerid || printer.assetid}`
|
||||
QRCode.toCanvas(canvas, 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)
|
||||
next[`${pageIdx}-${pos}`] = await renderQrDataUrl(qrUrl)
|
||||
}
|
||||
}
|
||||
const svgStr = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>`
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr)
|
||||
qrImages.value = next
|
||||
}
|
||||
|
||||
function displayName(printer) {
|
||||
@@ -193,26 +162,27 @@ function print() {
|
||||
@page { size: letter; margin: 0; }
|
||||
|
||||
.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; }
|
||||
|
||||
.print-btn {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: #667eea;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
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 {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: #dc3545;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
@@ -223,7 +193,7 @@ function print() {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: #28a745;
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
@@ -235,31 +205,32 @@ function print() {
|
||||
gap: 10px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px;
|
||||
background: #fafafa;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.printer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.printer-item:hover { background: #f0f0f0; }
|
||||
.printer-item.selected { background: #e7f1ff; border-color: #667eea; }
|
||||
.printer-item:hover { border-color: var(--primary); }
|
||||
.printer-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
|
||||
.printer-item input { margin-right: 10px; }
|
||||
.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 .count { color: #667eea; }
|
||||
.selected-count .pages { color: #28a745; }
|
||||
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
|
||||
.selected-count .count { color: var(--primary); }
|
||||
.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; }
|
||||
|
||||
@@ -288,9 +259,11 @@ function print() {
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed #ccc;
|
||||
}
|
||||
.label.filled { border: 2px solid #667eea; }
|
||||
.label.filled { border: 2px solid var(--primary); }
|
||||
.label.empty { background: #fafafa; }
|
||||
|
||||
.qr-img { width: 144px; height: 144px; display: block; }
|
||||
|
||||
.pos-1 { top: 0.875in; left: 1.1875in; }
|
||||
.pos-2 { top: 0.875in; left: 4.3125in; }
|
||||
.pos-3 { top: 4in; left: 1.1875in; }
|
||||
@@ -307,6 +280,13 @@ function print() {
|
||||
.empty-label { color: #999; font-size: 14px; }
|
||||
|
||||
@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; }
|
||||
.no-print { display: none !important; }
|
||||
.sheets-container { gap: 0; }
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<template v-if="pos === parseInt(position)">
|
||||
<div class="model-name">{{ printer.printer?.modelname || '' }}</div>
|
||||
<div class="qr-container">
|
||||
<canvas ref="qrCanvas"></canvas>
|
||||
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<div class="csf-name">{{ printer.assetnumber }}</div>
|
||||
@@ -47,13 +47,15 @@
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { printersApi } from '../../api'
|
||||
import QRCode from 'qrcode'
|
||||
import { renderQrDataUrl } from './qrLogo'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const printer = ref(null)
|
||||
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(() => {
|
||||
// Check direct ipaddress field first (from list API)
|
||||
@@ -82,42 +84,10 @@ watch(position, async () => {
|
||||
generateQR()
|
||||
})
|
||||
|
||||
function generateQR() {
|
||||
const canvas = Array.isArray(qrCanvas.value) ? qrCanvas.value[0] : qrCanvas.value
|
||||
if (!canvas || !printer.value) return
|
||||
|
||||
async function generateQR() {
|
||||
if (!printer.value) return
|
||||
const qrUrl = `${window.location.origin}/printers/${printer.value.printer?.printerid || printer.value.assetid}`
|
||||
QRCode.toCanvas(canvas, 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)
|
||||
qrImage.value = await renderQrDataUrl(qrUrl)
|
||||
}
|
||||
|
||||
function print() {
|
||||
@@ -134,13 +104,14 @@ function print() {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: #667eea;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 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; }
|
||||
|
||||
@@ -148,7 +119,7 @@ function print() {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
font-size: 1.125rem;
|
||||
color: #666;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.print-sheet {
|
||||
@@ -172,7 +143,9 @@ function print() {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.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-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; }
|
||||
|
||||
@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; }
|
||||
.no-print { display: none !important; }
|
||||
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
|
||||
|
||||
@@ -207,26 +207,27 @@ function print() {
|
||||
@page { size: letter; margin: 0; }
|
||||
|
||||
.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; }
|
||||
|
||||
.print-btn {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: #667eea;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
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 {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: #dc3545;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
@@ -237,7 +238,7 @@ function print() {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: #28a745;
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
@@ -249,31 +250,32 @@ function print() {
|
||||
gap: 10px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px;
|
||||
background: #fafafa;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.usb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.usb-item:hover { background: #f0f0f0; }
|
||||
.usb-item.selected { background: #e7f1ff; border-color: #667eea; }
|
||||
.usb-item:hover { border-color: var(--primary); }
|
||||
.usb-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
|
||||
.usb-item input { margin-right: 10px; }
|
||||
.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 .count { color: #667eea; }
|
||||
.selected-count .pages { color: #28a745; }
|
||||
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
|
||||
.selected-count .count { color: var(--primary); }
|
||||
.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; }
|
||||
|
||||
@@ -298,7 +300,7 @@ function print() {
|
||||
border: 1px dashed #ccc;
|
||||
overflow: hidden;
|
||||
}
|
||||
.label-cell.has-content { border: 1px solid #667eea; }
|
||||
.label-cell.has-content { border: 1px solid var(--primary); }
|
||||
.label-cell.empty { background: #fafafa; }
|
||||
|
||||
.cell-1 { top: 0.875in; left: 1.1875in; }
|
||||
@@ -353,6 +355,12 @@ function 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; }
|
||||
.no-print { display: none !important; }
|
||||
.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-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ printer.name || printer.assetnumber }}</h1>
|
||||
<h1>{{ displayTitle }}</h1>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg badge-printer">Printer</span>
|
||||
@@ -63,7 +63,7 @@
|
||||
<span class="info-label">Windows Name</span>
|
||||
<span class="info-value mono">{{ printer.printer.windowsname }}</span>
|
||||
</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-value mono">{{ printer.printer.hostname }}</span>
|
||||
</div>
|
||||
@@ -75,6 +75,14 @@
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ printer.serialnumber }}</span>
|
||||
</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>
|
||||
|
||||
@@ -172,23 +180,30 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<span class="supply-name">{{ supply.supplyname }}</span>
|
||||
<span class="supply-level" :class="getSupplyLevelClass(supply.currentlevel)">
|
||||
{{ supply.currentlevel !== null ? `${supply.currentlevel}%` : 'N/A' }}
|
||||
<span class="supply-name">{{ supply.name }}</span>
|
||||
<span class="supply-level" :class="supply.status">
|
||||
{{ supply.level !== null ? `${supply.level}%` : 'N/A' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="supply-bar">
|
||||
<div
|
||||
class="supply-bar-fill"
|
||||
:class="getSupplyLevelClass(supply.currentlevel)"
|
||||
:style="{ width: `${supply.currentlevel || 0}%` }"
|
||||
:class="supply.status"
|
||||
:style="{ width: `${supply.level || 0}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="supply-meta">
|
||||
<span>{{ supply.supplytypename }}</span>
|
||||
<span v-if="supply.partnumber">Part: {{ supply.partnumber }}</span>
|
||||
<span>{{ formatSupplyType(supply.supplytype) }}<template v-if="supply.iswaste"> ({{ supply.remaining }}% remaining)</template></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>
|
||||
@@ -238,14 +253,26 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { printersApi } from '../../api'
|
||||
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
|
||||
const route = useRoute()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const printer = ref(null)
|
||||
const supplies = 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
|
||||
const ipAddress = computed(() => {
|
||||
if (!printer.value?.communications) return null
|
||||
@@ -262,7 +289,8 @@ onMounted(async () => {
|
||||
])
|
||||
|
||||
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 || []
|
||||
} catch (error) {
|
||||
console.error('Error loading printer:', error)
|
||||
@@ -280,11 +308,9 @@ function getStatusClass(status) {
|
||||
return 'badge-info'
|
||||
}
|
||||
|
||||
function getSupplyLevelClass(level) {
|
||||
if (level === null || level === undefined) return ''
|
||||
if (level <= 10) return 'critical'
|
||||
if (level <= 25) return 'low'
|
||||
return 'ok'
|
||||
function formatSupplyType(supplytype) {
|
||||
if (!supplytype) return ''
|
||||
return supplytype.charAt(0).toUpperCase() + supplytype.slice(1)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
@@ -360,4 +386,33 @@ function formatDate(dateStr) {
|
||||
color: var(--text-light);
|
||||
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>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<div class="form-group" v-if="isEnabled('fqdn', 'printer')">
|
||||
<label for="hostname">Hostname (FQDN)</label>
|
||||
<input
|
||||
id="hostname"
|
||||
@@ -61,6 +61,28 @@
|
||||
</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-group">
|
||||
<label for="machinetypeid">Printer Type *</label>
|
||||
@@ -73,11 +95,11 @@
|
||||
>
|
||||
<option value="">Select type...</option>
|
||||
<option
|
||||
v-for="mt in printerTypes"
|
||||
:key="mt.machinetypeid"
|
||||
:value="mt.machinetypeid"
|
||||
v-for="pt in printerTypes"
|
||||
:key="pt.printertypeid"
|
||||
:value="pt.printertypeid"
|
||||
>
|
||||
{{ mt.machinetype }}
|
||||
{{ pt.printertype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -127,8 +149,9 @@
|
||||
id="modelnumberid"
|
||||
v-model="form.modelnumberid"
|
||||
class="form-control"
|
||||
:disabled="!form.vendorid"
|
||||
>
|
||||
<option value="">Select model...</option>
|
||||
<option value="">{{ form.vendorid ? 'Select model...' : 'Select a vendor first' }}</option>
|
||||
<option
|
||||
v-for="m in filteredModels"
|
||||
:key="m.modelnumberid"
|
||||
@@ -137,8 +160,8 @@
|
||||
{{ m.modelnumber }}
|
||||
</option>
|
||||
</select>
|
||||
<small v-if="!form.vendorid && !form.machinetypeid" class="form-hint">
|
||||
Select vendor or printer type to filter models
|
||||
<small v-if="!form.vendorid" class="form-hint">
|
||||
Select a vendor first to choose a model
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,10 +289,13 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
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 Modal from '../../components/Modal.vue'
|
||||
import { currentTheme } from '../../stores/theme'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -289,6 +315,8 @@ const form = ref({
|
||||
alias: '',
|
||||
hostname: '',
|
||||
serialnumber: '',
|
||||
gaugelabreference: '',
|
||||
maintenancereference: '',
|
||||
machinetypeid: '',
|
||||
statusid: '',
|
||||
vendorid: '',
|
||||
@@ -317,8 +345,10 @@ const filteredModels = computed(() => {
|
||||
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
|
||||
return false
|
||||
}
|
||||
// Filter by printer type if selected
|
||||
if (form.value.machinetypeid && m.machinetypeid !== form.value.machinetypeid) {
|
||||
// Filter by printer type if selected, but only exclude models that have a
|
||||
// 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 true
|
||||
@@ -422,47 +452,55 @@ function onWindowsNameInput() {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load reference data
|
||||
const [mtRes, statusRes, vendorRes, modelsRes, locRes] = await Promise.all([
|
||||
machinetypesApi.list({ category: 'Printer' }),
|
||||
statusesApi.list(),
|
||||
vendorsApi.list(),
|
||||
modelsApi.list(),
|
||||
locationsApi.list()
|
||||
// Load reference data (models paged in full via listAll, see api/index.js)
|
||||
// perpage 100 so dropdowns aren't truncated to the default 20-row page
|
||||
// (e.g. 44 vendors; the editing record's vendor can be past row 20)
|
||||
const [mtRes, statusRes, vendorRes, allModels, locRes] = await Promise.all([
|
||||
printersApi.types.list({ perpage: 100 }),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 100 }),
|
||||
modelsApi.listAll(),
|
||||
locationsApi.list({ perpage: 100 })
|
||||
])
|
||||
|
||||
printerTypes.value = mtRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
models.value = modelsRes.data.data || []
|
||||
models.value = allModels
|
||||
locations.value = locRes.data.data || []
|
||||
|
||||
// Load printer if editing
|
||||
if (isEdit.value) {
|
||||
const response = await printersApi.get(route.params.id)
|
||||
const printer = response.data.data
|
||||
// asset-based shape: printer extension fields live under printer.printer
|
||||
const ext = printer.printer || {}
|
||||
|
||||
// Get IP from communications
|
||||
const primaryComm = printer.communications?.find(c => c.isprimary) || printer.communications?.[0]
|
||||
|
||||
form.value = {
|
||||
machinenumber: printer.machinenumber || '',
|
||||
alias: printer.alias || '',
|
||||
hostname: printer.hostname || '',
|
||||
// "Windows Name" is the printer's business identifier (assetnumber).
|
||||
// Prefer the explicit windowsname, then fall back to assetnumber.
|
||||
machinenumber: ext.windowsname || printer.assetnumber || '',
|
||||
alias: printer.name && printer.name.toUpperCase() !== 'NONE' ? printer.name : '',
|
||||
hostname: ext.hostname || '',
|
||||
serialnumber: printer.serialnumber || '',
|
||||
machinetypeid: printer.machinetype?.machinetypeid || '',
|
||||
statusid: printer.status?.statusid || '',
|
||||
vendorid: printer.vendor?.vendorid || '',
|
||||
modelnumberid: printer.model?.modelnumberid || '',
|
||||
locationid: printer.location?.locationid || '',
|
||||
gaugelabreference: printer.gaugelabreference || '',
|
||||
maintenancereference: printer.maintenancereference || '',
|
||||
machinetypeid: ext.printertypeid || '',
|
||||
statusid: printer.statusid || '',
|
||||
vendorid: ext.vendorid || '',
|
||||
modelnumberid: ext.modelnumberid || '',
|
||||
locationid: printer.locationid || '',
|
||||
notes: printer.notes || '',
|
||||
mapx: printer.mapx ?? null,
|
||||
mapy: printer.mapy ?? null,
|
||||
// Printer-specific
|
||||
ipaddress: primaryComm?.ipaddress || '',
|
||||
csfname: printer.printerdata?.sharename || '',
|
||||
installpath: printer.printerdata?.installpath || '',
|
||||
pin: printer.printerdata?.pin || ''
|
||||
csfname: ext.sharename || '',
|
||||
installpath: ext.installpath || '',
|
||||
pin: ext.pin || ''
|
||||
}
|
||||
|
||||
// Don't auto-generate for existing printers
|
||||
@@ -500,48 +538,38 @@ async function savePrinter() {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const machineData = {
|
||||
machinenumber: form.value.machinenumber,
|
||||
alias: form.value.alias,
|
||||
hostname: form.value.hostname,
|
||||
serialnumber: form.value.serialnumber,
|
||||
machinetypeid: form.value.machinetypeid || null,
|
||||
// One payload for the printers plugin, which owns asset core + extension +
|
||||
// primary communication. The "Windows Name" field is the business
|
||||
// identifier, written to both assetnumber and the extension windowsname.
|
||||
const payload = {
|
||||
assetnumber: form.value.machinenumber,
|
||||
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,
|
||||
vendorid: form.value.vendorid || null,
|
||||
modelnumberid: form.value.modelnumberid || 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,
|
||||
mapy: form.value.mapy
|
||||
}
|
||||
|
||||
const printerData = {
|
||||
windowsname: form.value.machinenumber, // Windows name is the machinenumber
|
||||
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
|
||||
// only set the display name when an alias is given, so we don't clobber it
|
||||
if (form.value.alias) {
|
||||
payload.name = form.value.alias
|
||||
}
|
||||
|
||||
// Handle IP address - need to update/create communication record
|
||||
const communicationData = form.value.ipaddress ? {
|
||||
ipaddress: form.value.ipaddress,
|
||||
isprimary: true
|
||||
} : null
|
||||
|
||||
if (isEdit.value) {
|
||||
await machinesApi.update(route.params.id, machineData)
|
||||
await printersApi.updateExtension(route.params.id, printerData)
|
||||
if (communicationData) {
|
||||
await printersApi.updateCommunication(route.params.id, communicationData)
|
||||
}
|
||||
await printersApi.update(route.params.id, payload)
|
||||
} else {
|
||||
const response = await machinesApi.create(machineData)
|
||||
const newId = response.data.data.machineid
|
||||
await printersApi.updateExtension(newId, printerData)
|
||||
if (communicationData) {
|
||||
await printersApi.updateCommunication(newId, communicationData)
|
||||
}
|
||||
await printersApi.create(payload)
|
||||
}
|
||||
|
||||
router.push('/printers')
|
||||
@@ -592,12 +620,11 @@ async function savePrinter() {
|
||||
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 {
|
||||
background-color: #f0f7ff;
|
||||
background-color: rgba(33, 150, 243, 0.12);
|
||||
border-color: #90caf9;
|
||||
}
|
||||
|
||||
.auto-generated:focus {
|
||||
background-color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
placeholder="Search printers..."
|
||||
@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 class="card">
|
||||
@@ -27,9 +33,10 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset #</th>
|
||||
<th>Asset Tag</th>
|
||||
<th>Name</th>
|
||||
<th>Business Unit</th>
|
||||
<th>Type</th>
|
||||
<th>Model</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
@@ -38,8 +45,9 @@
|
||||
<tbody>
|
||||
<tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid">
|
||||
<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.printer?.printertypename || '-' }}</td>
|
||||
<td>{{ printer.printer?.modelname || '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="getStatusClass(printer.statusname)">
|
||||
@@ -56,7 +64,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<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
|
||||
</td>
|
||||
</tr>
|
||||
@@ -83,6 +91,8 @@ import { printersApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
|
||||
const printers = ref([])
|
||||
const printerTypes = ref([])
|
||||
const typeFilter = ref('')
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
@@ -91,7 +101,13 @@ const perPage = ref(20)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -103,6 +119,7 @@ async function loadPrinters() {
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
if (typeFilter.value) params.typeid = typeFilter.value
|
||||
|
||||
const response = await printersApi.list(params)
|
||||
printers.value = response.data.data || []
|
||||
@@ -122,6 +139,11 @@ function debouncedSearch() {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
page.value = 1
|
||||
loadPrinters()
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadPrinters()
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<tr v-for="bu in items" :key="bu.businessunitid">
|
||||
<td>{{ bu.businessunit }}</td>
|
||||
<td>{{ bu.code || '-' }}</td>
|
||||
<td>{{ bu.description || '-' }}</td>
|
||||
<td class="cell-truncate" :title="bu.description">{{ bu.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(bu)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="confirmDelete(bu)">Delete</button>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Location Name</th>
|
||||
<th>Type</th>
|
||||
<th>Building</th>
|
||||
<th>Floor</th>
|
||||
<th>Room</th>
|
||||
@@ -35,10 +36,11 @@
|
||||
<tbody>
|
||||
<tr v-for="loc in locations" :key="loc.locationid">
|
||||
<td>{{ loc.locationname }}</td>
|
||||
<td>{{ loc.locationtypename || '-' }}</td>
|
||||
<td>{{ loc.building || '-' }}</td>
|
||||
<td>{{ loc.floor || '-' }}</td>
|
||||
<td>{{ loc.room || '-' }}</td>
|
||||
<td>{{ loc.description || '-' }}</td>
|
||||
<td class="cell-truncate" :title="loc.description">{{ loc.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
@@ -55,7 +57,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<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
|
||||
</td>
|
||||
</tr>
|
||||
@@ -125,6 +127,32 @@
|
||||
</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">
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
@@ -180,11 +208,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { locationsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
|
||||
const locations = ref([])
|
||||
const locationTypes = ref([])
|
||||
const allLocations = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
@@ -205,12 +235,29 @@ const form = ref({
|
||||
floor: '',
|
||||
room: '',
|
||||
description: '',
|
||||
locationtypeid: '',
|
||||
parentlocationid: '',
|
||||
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
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -261,6 +308,8 @@ function openModal(loc = null) {
|
||||
floor: loc.floor || '',
|
||||
room: loc.room || '',
|
||||
description: loc.description || '',
|
||||
locationtypeid: loc.locationtypeid || '',
|
||||
parentlocationid: loc.parentlocationid || '',
|
||||
mapimage: loc.mapimage || ''
|
||||
}
|
||||
} else {
|
||||
@@ -270,6 +319,8 @@ function openModal(loc = null) {
|
||||
floor: '',
|
||||
room: '',
|
||||
description: '',
|
||||
locationtypeid: '',
|
||||
parentlocationid: '',
|
||||
mapimage: ''
|
||||
}
|
||||
}
|
||||
@@ -287,10 +338,15 @@ async function saveLocation() {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...form.value,
|
||||
locationtypeid: form.value.locationtypeid || null,
|
||||
parentlocationid: form.value.parentlocationid || null
|
||||
}
|
||||
if (editingLocation.value) {
|
||||
await locationsApi.update(editingLocation.value.locationid, form.value)
|
||||
await locationsApi.update(editingLocation.value.locationid, payload)
|
||||
} else {
|
||||
await locationsApi.create(form.value)
|
||||
await locationsApi.create(payload)
|
||||
}
|
||||
closeModal()
|
||||
loadLocations()
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{{ mt.category }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ mt.description || '-' }}</td>
|
||||
<td class="cell-truncate" :title="mt.description">{{ mt.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
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>
|
||||
@@ -1,177 +1,126 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>PC Types</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add PC Type</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PC Type</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pt in pcTypes" :key="pt.pctypeid">
|
||||
<td>{{ pt.pctype }}</td>
|
||||
<td>{{ pt.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<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>
|
||||
</tr>
|
||||
<tr v-if="pcTypes.length === 0">
|
||||
<td colspan="3" style="text-align: center; color: var(--text-light);">
|
||||
No PC types found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit PC Type' : 'Add PC Type' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="pctype">PC Type *</label>
|
||||
<input id="pctype" v-model="form.pctype" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { pctypesApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
|
||||
const pcTypes = ref([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const toDelete = ref(null)
|
||||
|
||||
const form = ref({ pctype: '', description: '' })
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await pctypesApi.list({ page: page.value, perpage: perPage.value })
|
||||
pcTypes.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading PC types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p) { page.value = p; loadData() }
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item ? { pctype: item.pctype || '', description: item.description || '' } : { pctype: '', description: '' }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await pctypesApi.update(editing.value.pctypeid, form.value)
|
||||
} else {
|
||||
await pctypesApi.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || 'Failed to save'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
|
||||
|
||||
async function deleteItem() {
|
||||
try {
|
||||
await pctypesApi.delete(toDelete.value.pctypeid)
|
||||
showDeleteModal.value = false
|
||||
toDelete.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
alert('Failed to delete')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>PC Types</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add PC Type</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PC Type</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pt in pcTypes" :key="pt.computertypeid">
|
||||
<td>{{ pt.computertype }}</td>
|
||||
<td class="cell-truncate" :title="pt.description">{{ pt.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="pcTypes.length === 0">
|
||||
<td colspan="3" style="text-align: center; color: var(--text-light);">
|
||||
No PC types found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit PC Type' : 'Add PC Type' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="computertype">PC Type *</label>
|
||||
<input id="computertype" v-model="form.computertype" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computersApi } from '../../api'
|
||||
|
||||
const pcTypes = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({ computertype: '', description: '' })
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await computersApi.types.list({ perpage: 100 })
|
||||
pcTypes.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading PC types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? { computertype: item.computertype || '', description: item.description || '' }
|
||||
: { computertype: '', description: '' }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await computersApi.types.update(editing.value.computertypeid, form.value)
|
||||
} else {
|
||||
await computersApi.types.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || 'Failed to save'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</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>
|
||||
</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">
|
||||
<div class="card-icon"><Monitor :size="28" /></div>
|
||||
<h3>Machine Types</h3>
|
||||
@@ -69,6 +75,12 @@
|
||||
<p>Configure integrations and system options</p>
|
||||
</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">
|
||||
<div class="card-icon"><FileText :size="28" /></div>
|
||||
<h3>Audit Logs</h3>
|
||||
@@ -85,7 +97,7 @@
|
||||
</template>
|
||||
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Machine Statuses</h2>
|
||||
<h2>Asset Statuses</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Status</button>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<span class="color-preview" :style="{ backgroundColor: s.color || '#6c757d' }"></span>
|
||||
{{ s.color || 'default' }}
|
||||
</td>
|
||||
<td>{{ s.description || '-' }}</td>
|
||||
<td class="cell-truncate" :title="s.description">{{ s.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
@@ -135,7 +135,7 @@
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ statusToDelete?.status }}</strong>?</p>
|
||||
<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>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -149,7 +149,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { statusesApi } from '../../api'
|
||||
import { assetsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
|
||||
const statuses = ref([])
|
||||
@@ -184,7 +184,7 @@ async function loadStatuses() {
|
||||
perpage: perPage.value
|
||||
}
|
||||
|
||||
const response = await statusesApi.list(params)
|
||||
const response = await assetsApi.statuses.list(params)
|
||||
statuses.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (err) {
|
||||
@@ -235,9 +235,9 @@ async function saveStatus() {
|
||||
|
||||
try {
|
||||
if (editingStatus.value) {
|
||||
await statusesApi.update(editingStatus.value.statusid, form.value)
|
||||
await assetsApi.statuses.update(editingStatus.value.statusid, form.value)
|
||||
} else {
|
||||
await statusesApi.create(form.value)
|
||||
await assetsApi.statuses.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadStatuses()
|
||||
@@ -256,7 +256,7 @@ function confirmDelete(s) {
|
||||
|
||||
async function deleteStatus() {
|
||||
try {
|
||||
await statusesApi.delete(statusToDelete.value.statusid)
|
||||
await assetsApi.statuses.delete(statusToDelete.value.statusid)
|
||||
showDeleteModal.value = false
|
||||
statusToDelete.value = null
|
||||
loadStatuses()
|
||||
|
||||
@@ -375,6 +375,45 @@
|
||||
</router-link>
|
||||
</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 v-if="error" class="error-message">{{ error }}</div>
|
||||
@@ -384,6 +423,7 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { settingsApi } from '../../api'
|
||||
import { setIdentifierFlag } from '../../composables/identifierSettings'
|
||||
|
||||
const settings = reactive({
|
||||
// Zabbix
|
||||
@@ -412,6 +452,30 @@ const settings = reactive({
|
||||
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 saving = ref(false)
|
||||
const testingEmail = ref(false)
|
||||
@@ -472,6 +536,8 @@ async function loadSettings() {
|
||||
for (const setting of data.data) {
|
||||
if (setting.key in settings) {
|
||||
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) {
|
||||
@@ -487,6 +553,44 @@ async function toggleSetting(key) {
|
||||
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) {
|
||||
try {
|
||||
saving.value = true
|
||||
@@ -739,4 +843,31 @@ onMounted(loadSettings)
|
||||
margin-top: -0.5rem;
|
||||
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>
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
{{ role.rolename }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ role.description || '-' }}</td>
|
||||
<td class="cell-truncate" :title="role.description">{{ role.description || '-' }}</td>
|
||||
<td>
|
||||
<span v-if="role.isadmin" class="text-muted">All permissions</span>
|
||||
<span v-else-if="role.permissions?.length">{{ role.permissions.length }} permissions</span>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>{{ vlan.description || '-' }}</td>
|
||||
<td class="cell-truncate" :title="vlan.description">{{ vlan.description || '-' }}</td>
|
||||
<td>
|
||||
<router-link
|
||||
: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_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog, Communication, CommunicationType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
|
||||
from ..models import Computer, ComputerType, ComputerInstalledApp
|
||||
|
||||
@@ -162,19 +154,19 @@ def list_computers():
|
||||
)
|
||||
|
||||
# Computer type filter
|
||||
if type_id := request.args.get('type_id'):
|
||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
||||
query = query.filter(Computer.computertypeid == int(type_id))
|
||||
|
||||
# OS filter
|
||||
if os_id := request.args.get('os_id'):
|
||||
if os_id := request.args.get('osid', request.args.get('os_id')):
|
||||
query = query.filter(Computer.osid == int(os_id))
|
||||
|
||||
# Location filter
|
||||
if location_id := request.args.get('location_id'):
|
||||
if location_id := request.args.get('locationid', request.args.get('location_id')):
|
||||
query = query.filter(Asset.locationid == int(location_id))
|
||||
|
||||
# Business unit filter
|
||||
if bu_id := request.args.get('businessunit_id'):
|
||||
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
||||
|
||||
# Shopfloor filter
|
||||
@@ -225,6 +217,10 @@ def get_computer(computer_id: int):
|
||||
|
||||
result = comp.asset.to_dict() if comp.asset else {}
|
||||
result['computer'] = comp.to_dict()
|
||||
result['communications'] = [
|
||||
c.to_dict() for c in
|
||||
Communication.query.filter_by(assetid=comp.assetid).all()
|
||||
]
|
||||
|
||||
return success_response(result)
|
||||
|
||||
@@ -321,6 +317,8 @@ def create_computer():
|
||||
assetnumber=data['assetnumber'],
|
||||
name=data.get('name'),
|
||||
serialnumber=data.get('serialnumber'),
|
||||
gaugelabreference=data.get('gaugelabreference'),
|
||||
maintenancereference=data.get('maintenancereference'),
|
||||
assettypeid=computer_type.assettypeid,
|
||||
statusid=data.get('statusid', 1),
|
||||
locationid=data.get('locationid'),
|
||||
@@ -339,6 +337,8 @@ def create_computer():
|
||||
computertypeid=data.get('computertypeid'),
|
||||
hostname=data.get('hostname'),
|
||||
osid=data.get('osid'),
|
||||
vendorid=data.get('vendorid'),
|
||||
modelnumberid=data.get('modelnumberid'),
|
||||
loggedinuser=data.get('loggedinuser'),
|
||||
lastreporteddate=data.get('lastreporteddate'),
|
||||
lastboottime=data.get('lastboottime'),
|
||||
@@ -350,6 +350,17 @@ def create_computer():
|
||||
db.session.add(comp)
|
||||
db.session.flush()
|
||||
|
||||
# Optional primary IP communication
|
||||
if data.get('ipaddress'):
|
||||
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
||||
if ip_comtype:
|
||||
db.session.add(Communication(
|
||||
assetid=asset.assetid,
|
||||
comtypeid=ip_comtype.comtypeid,
|
||||
ipaddress=data['ipaddress'],
|
||||
isprimary=True,
|
||||
))
|
||||
|
||||
# Audit log
|
||||
AuditLog.log('created', 'Computer', entityid=comp.computerid,
|
||||
entityname=data.get('hostname') or data['assetnumber'])
|
||||
@@ -404,7 +415,8 @@ def update_computer(computer_id: int):
|
||||
changes = {}
|
||||
|
||||
# Update asset fields
|
||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
|
||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
||||
'maintenancereference', 'statusid',
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
|
||||
for key in asset_fields:
|
||||
if key in data:
|
||||
@@ -415,8 +427,9 @@ def update_computer(computer_id: int):
|
||||
setattr(asset, key, data[key])
|
||||
|
||||
# Update computer fields
|
||||
computer_fields = ['computertypeid', 'hostname', 'osid', 'loggedinuser',
|
||||
'lastreporteddate', 'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor']
|
||||
computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid',
|
||||
'modelnumberid', 'loggedinuser', 'lastreporteddate',
|
||||
'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor']
|
||||
for key in computer_fields:
|
||||
if key in data:
|
||||
old_val = getattr(comp, key)
|
||||
@@ -425,6 +438,23 @@ def update_computer(computer_id: int):
|
||||
changes[key] = {'old': old_val, 'new': new_val}
|
||||
setattr(comp, key, data[key])
|
||||
|
||||
# Upsert the primary IP communication so a single PUT covers it
|
||||
if 'ipaddress' in data:
|
||||
ip = (data.get('ipaddress') or '').strip()
|
||||
primary = Communication.query.filter_by(
|
||||
assetid=asset.assetid, isprimary=True).first()
|
||||
if ip:
|
||||
if primary:
|
||||
primary.ipaddress = ip
|
||||
else:
|
||||
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
||||
if ip_comtype:
|
||||
db.session.add(Communication(
|
||||
assetid=asset.assetid, comtypeid=ip_comtype.comtypeid,
|
||||
ipaddress=ip, isprimary=True))
|
||||
elif primary:
|
||||
primary.ipaddress = None
|
||||
|
||||
# Audit log if there were changes
|
||||
if changes:
|
||||
AuditLog.log('updated', 'Computer', entityid=comp.computerid,
|
||||
|
||||
@@ -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,184 +1,203 @@
|
||||
"""Computer plugin models."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models.base import BaseModel
|
||||
|
||||
|
||||
class ComputerType(BaseModel):
|
||||
"""
|
||||
Computer type classification.
|
||||
|
||||
Examples: Shopfloor PC, Engineer Workstation, CMM PC, Server, etc.
|
||||
"""
|
||||
__tablename__ = 'computertypes'
|
||||
|
||||
computertypeid = db.Column(db.Integer, primary_key=True)
|
||||
computertype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ComputerType {self.computertype}>"
|
||||
|
||||
|
||||
class Computer(BaseModel):
|
||||
"""
|
||||
Computer-specific extension data.
|
||||
|
||||
Links to core Asset table via assetid.
|
||||
Stores computer-specific fields like hostname, OS, logged in user, etc.
|
||||
"""
|
||||
__tablename__ = 'computers'
|
||||
|
||||
computerid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Link to core asset
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Computer classification
|
||||
computertypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('computertypes.computertypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Network identity
|
||||
hostname = db.Column(
|
||||
db.String(100),
|
||||
index=True,
|
||||
comment='Network hostname'
|
||||
)
|
||||
|
||||
# Operating system
|
||||
osid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('operatingsystems.osid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Status tracking
|
||||
loggedinuser = db.Column(db.String(100), nullable=True)
|
||||
lastreporteddate = db.Column(db.DateTime, nullable=True)
|
||||
lastboottime = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Remote access features
|
||||
isvnc = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='VNC remote access enabled'
|
||||
)
|
||||
iswinrm = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='WinRM enabled'
|
||||
)
|
||||
|
||||
# Classification flags
|
||||
isshopfloor = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Shopfloor PC (vs office PC)'
|
||||
)
|
||||
|
||||
# Relationships
|
||||
asset = db.relationship(
|
||||
'Asset',
|
||||
backref=db.backref('computer', uselist=False, lazy='joined')
|
||||
)
|
||||
computertype = db.relationship('ComputerType', backref='computers')
|
||||
operatingsystem = db.relationship('OperatingSystem', backref='computers')
|
||||
|
||||
# Installed applications (one-to-many)
|
||||
installedapps = db.relationship(
|
||||
'ComputerInstalledApp',
|
||||
back_populates='computer',
|
||||
cascade='all, delete-orphan',
|
||||
lazy='dynamic'
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_computer_type', 'computertypeid'),
|
||||
db.Index('idx_computer_hostname', 'hostname'),
|
||||
db.Index('idx_computer_os', 'osid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Computer {self.hostname or self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related names."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names
|
||||
if self.computertype:
|
||||
result['computertypename'] = self.computertype.computertype
|
||||
if self.operatingsystem:
|
||||
result['osname'] = self.operatingsystem.osname
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ComputerInstalledApp(db.Model):
|
||||
"""
|
||||
Junction table for applications installed on computers.
|
||||
|
||||
Tracks which applications are installed on which computers,
|
||||
including version information.
|
||||
"""
|
||||
__tablename__ = 'computerinstalledapps'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
computerid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('computers.computerid', ondelete='CASCADE'),
|
||||
nullable=False
|
||||
)
|
||||
appid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('applications.appid'),
|
||||
nullable=False
|
||||
)
|
||||
appversionid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('appversions.appversionid'),
|
||||
nullable=True
|
||||
)
|
||||
isactive = db.Column(db.Boolean, default=True, nullable=False)
|
||||
installeddate = db.Column(db.DateTime, default=db.func.now())
|
||||
|
||||
# Relationships
|
||||
computer = db.relationship('Computer', back_populates='installedapps')
|
||||
application = db.relationship('Application')
|
||||
appversion = db.relationship('AppVersion')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('computerid', 'appid', name='uq_computer_app'),
|
||||
db.Index('idx_compapp_computer', 'computerid'),
|
||||
db.Index('idx_compapp_app', 'appid'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'computerid': self.computerid,
|
||||
'appid': self.appid,
|
||||
'appversionid': self.appversionid,
|
||||
'isactive': self.isactive,
|
||||
'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None,
|
||||
'application': {
|
||||
'appid': self.application.appid,
|
||||
'appname': self.application.appname,
|
||||
'appdescription': self.application.appdescription,
|
||||
} if self.application else None,
|
||||
'version': self.appversion.version if self.appversion else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ComputerInstalledApp computer={self.computerid} app={self.appid}>"
|
||||
"""Computer plugin models."""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class ComputerType(BaseModel):
|
||||
"""
|
||||
Computer type classification.
|
||||
|
||||
Examples: Shopfloor PC, Engineer Workstation, CMM PC, Server, etc.
|
||||
"""
|
||||
__tablename__ = 'computertypes'
|
||||
|
||||
computertypeid = db.Column(db.Integer, primary_key=True)
|
||||
computertype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ComputerType {self.computertype}>"
|
||||
|
||||
|
||||
class Computer(BaseModel):
|
||||
"""
|
||||
Computer-specific extension data.
|
||||
|
||||
Links to core Asset table via assetid.
|
||||
Stores computer-specific fields like hostname, OS, logged in user, etc.
|
||||
"""
|
||||
__tablename__ = 'computers'
|
||||
|
||||
computerid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Link to core asset
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Computer classification
|
||||
computertypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('computertypes.computertypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Network identity
|
||||
hostname = db.Column(
|
||||
db.String(100),
|
||||
index=True,
|
||||
comment='Network hostname'
|
||||
)
|
||||
|
||||
# Operating system
|
||||
osid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('operatingsystems.osid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Hardware make/model (PCs carry vendor + model like equipment)
|
||||
vendorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vendors.vendorid'),
|
||||
nullable=True
|
||||
)
|
||||
modelnumberid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('models.modelnumberid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Status tracking
|
||||
loggedinuser = db.Column(db.String(100), nullable=True)
|
||||
lastreporteddate = db.Column(db.DateTime, nullable=True)
|
||||
lastboottime = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Remote access features
|
||||
isvnc = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='VNC remote access enabled'
|
||||
)
|
||||
iswinrm = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='WinRM enabled'
|
||||
)
|
||||
|
||||
# Classification flags
|
||||
isshopfloor = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Shopfloor PC (vs office PC)'
|
||||
)
|
||||
|
||||
# Relationships
|
||||
asset = db.relationship(
|
||||
'Asset',
|
||||
backref=db.backref('computer', uselist=False, lazy='joined')
|
||||
)
|
||||
computertype = db.relationship('ComputerType', backref='computers')
|
||||
operatingsystem = db.relationship('OperatingSystem', backref='computers')
|
||||
vendor = db.relationship('Vendor')
|
||||
model = db.relationship('Model')
|
||||
|
||||
# Installed applications (one-to-many)
|
||||
installedapps = db.relationship(
|
||||
'ComputerInstalledApp',
|
||||
back_populates='computer',
|
||||
cascade='all, delete-orphan',
|
||||
lazy='dynamic'
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_computer_type', 'computertypeid'),
|
||||
db.Index('idx_computer_hostname', 'hostname'),
|
||||
db.Index('idx_computer_os', 'osid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Computer {self.hostname or self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related names."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names
|
||||
if self.computertype:
|
||||
result['computertypename'] = self.computertype.computertype
|
||||
if self.operatingsystem:
|
||||
result['osname'] = self.operatingsystem.osname
|
||||
if self.vendor:
|
||||
result['vendorname'] = self.vendor.vendor
|
||||
if self.model:
|
||||
result['modelname'] = self.model.modelnumber
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ComputerInstalledApp(db.Model):
|
||||
"""
|
||||
Junction table for applications installed on computers.
|
||||
|
||||
Tracks which applications are installed on which computers,
|
||||
including version information.
|
||||
"""
|
||||
__tablename__ = 'computerinstalledapps'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
computerid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('computers.computerid', ondelete='CASCADE'),
|
||||
nullable=False
|
||||
)
|
||||
appid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('applications.appid'),
|
||||
nullable=False
|
||||
)
|
||||
appversionid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('appversions.appversionid'),
|
||||
nullable=True
|
||||
)
|
||||
# Raw version string from automated collection (when no curated AppVersion)
|
||||
installedversion = db.Column(db.String(100), nullable=True)
|
||||
isactive = db.Column(db.Boolean, default=True, nullable=False)
|
||||
installeddate = db.Column(db.DateTime, default=db.func.now())
|
||||
|
||||
# Relationships
|
||||
computer = db.relationship('Computer', back_populates='installedapps')
|
||||
application = db.relationship('Application')
|
||||
appversion = db.relationship('AppVersion')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('computerid', 'appid', name='uq_computer_app'),
|
||||
db.Index('idx_compapp_computer', 'computerid'),
|
||||
db.Index('idx_compapp_app', 'appid'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'computerid': self.computerid,
|
||||
'appid': self.appid,
|
||||
'appversionid': self.appversionid,
|
||||
'isactive': self.isactive,
|
||||
'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None,
|
||||
'application': {
|
||||
'appid': self.application.appid,
|
||||
'appname': self.application.appname,
|
||||
'appdescription': self.application.appdescription,
|
||||
} if self.application else None,
|
||||
'version': self.appversion.version if self.appversion else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ComputerInstalledApp computer={self.computerid} app={self.appid}>"
|
||||
|
||||
@@ -1,209 +1,306 @@
|
||||
"""Computers plugin main class."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import AssetType, AssetStatus
|
||||
|
||||
from .models import Computer, ComputerType, ComputerInstalledApp
|
||||
from .api import computers_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ComputersPlugin(BasePlugin):
|
||||
"""
|
||||
Computers plugin - manages PC, server, and workstation assets.
|
||||
|
||||
Computers include shopfloor PCs, engineer workstations, servers, etc.
|
||||
Uses the new Asset architecture with Computer extension table.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifestpath = Path(__file__).parent / 'manifest.json'
|
||||
if manifestpath.exists():
|
||||
with open(manifestpath, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'computers'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Computer management for PCs, servers, and workstations'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/computers'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
return computers_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [Computer, ComputerType, ComputerInstalledApp]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Computers plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
self._ensure_computer_types()
|
||||
logger.info("Computers plugin installed")
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
"""Ensure computer asset type exists."""
|
||||
existing = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not existing:
|
||||
at = AssetType(
|
||||
assettype='computer',
|
||||
pluginname='computers',
|
||||
tablename='computers',
|
||||
description='PCs, servers, and workstations',
|
||||
icon='desktop'
|
||||
)
|
||||
db.session.add(at)
|
||||
logger.debug("Created asset type: computer")
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_computer_types(self) -> None:
|
||||
"""Ensure basic computer types exist."""
|
||||
computer_types = [
|
||||
('Shopfloor PC', 'PC located on the shop floor for machine operation', 'desktop'),
|
||||
('Engineer Workstation', 'Engineering workstation for CAD/CAM work', 'laptop'),
|
||||
('CMM PC', 'PC dedicated to CMM operation', 'desktop'),
|
||||
('Server', 'Server system', 'server'),
|
||||
('Kiosk', 'Kiosk or info display PC', 'tv'),
|
||||
('Laptop', 'Laptop computer', 'laptop'),
|
||||
('Virtual Machine', 'Virtual machine', 'cloud'),
|
||||
('Other', 'Other computer type', 'desktop'),
|
||||
]
|
||||
|
||||
for name, description, icon in computer_types:
|
||||
existing = ComputerType.query.filter_by(computertype=name).first()
|
||||
if not existing:
|
||||
ct = ComputerType(
|
||||
computertype=name,
|
||||
description=description,
|
||||
icon=icon
|
||||
)
|
||||
db.session.add(ct)
|
||||
logger.debug(f"Created computer type: {name}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Computers plugin uninstalled")
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@click.group('computers')
|
||||
def computerscli():
|
||||
"""Computers plugin commands."""
|
||||
pass
|
||||
|
||||
@computerscli.command('list-types')
|
||||
def list_types():
|
||||
"""List all computer types."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
types = ComputerType.query.filter_by(isactive=True).all()
|
||||
if not types:
|
||||
click.echo('No computer types found.')
|
||||
return
|
||||
|
||||
click.echo('Computer Types:')
|
||||
for t in types:
|
||||
click.echo(f" [{t.computertypeid}] {t.computertype}")
|
||||
|
||||
@computerscli.command('stats')
|
||||
def stats():
|
||||
"""Show computer statistics."""
|
||||
from flask import current_app
|
||||
from shopdb.core.models import Asset
|
||||
|
||||
with current_app.app_context():
|
||||
total = db.session.query(Computer).join(Asset).filter(
|
||||
Asset.isactive == True
|
||||
).count()
|
||||
|
||||
click.echo(f"Total active computers: {total}")
|
||||
|
||||
# Shopfloor count
|
||||
shopfloor = db.session.query(Computer).join(Asset).filter(
|
||||
Asset.isactive == True,
|
||||
Computer.isshopfloor == True
|
||||
).count()
|
||||
|
||||
click.echo(f" Shopfloor PCs: {shopfloor}")
|
||||
click.echo(f" Other: {total - shopfloor}")
|
||||
|
||||
@computerscli.command('find')
|
||||
@click.argument('hostname')
|
||||
def find_by_hostname(hostname):
|
||||
"""Find a computer by hostname."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
comp = Computer.query.filter(
|
||||
Computer.hostname.ilike(f'%{hostname}%')
|
||||
).first()
|
||||
|
||||
if not comp:
|
||||
click.echo(f'No computer found matching hostname: {hostname}')
|
||||
return
|
||||
|
||||
click.echo(f'Found: {comp.hostname}')
|
||||
click.echo(f' Asset: {comp.asset.assetnumber}')
|
||||
click.echo(f' Type: {comp.computertype.computertype if comp.computertype else "N/A"}')
|
||||
click.echo(f' OS: {comp.operatingsystem.osname if comp.operatingsystem else "N/A"}')
|
||||
click.echo(f' Logged in: {comp.loggedinuser or "N/A"}')
|
||||
|
||||
return [computerscli]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Return dashboard widget definitions."""
|
||||
return [
|
||||
{
|
||||
'name': 'Computer Status',
|
||||
'component': 'ComputerStatusWidget',
|
||||
'endpoint': '/api/computers/dashboard/summary',
|
||||
'size': 'medium',
|
||||
'position': 6,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
{
|
||||
'name': 'PCs',
|
||||
'icon': 'desktop',
|
||||
'route': '/pcs',
|
||||
'position': 15,
|
||||
},
|
||||
]
|
||||
"""Computers plugin main class."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, AssetType
|
||||
|
||||
from .models import Computer, ComputerType, ComputerInstalledApp
|
||||
from .api import computers_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ComputersPlugin(BasePlugin):
|
||||
"""
|
||||
Computers plugin - manages PC, server, and workstation assets.
|
||||
|
||||
Computers include shopfloor PCs, engineer workstations, servers, etc.
|
||||
Uses the new Asset architecture with Computer extension table.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifestpath = Path(__file__).parent / 'manifest.json'
|
||||
if manifestpath.exists():
|
||||
with open(manifestpath, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'computers'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Computer management for PCs, servers, and workstations'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/computers'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
return computers_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [Computer, ComputerType, ComputerInstalledApp]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Computers plugin initialized (v{self.meta.version})")
|
||||
|
||||
# -- ADR-006 collector contract -----------------------------------------
|
||||
|
||||
def get_collector_schema(self) -> Optional[Dict]:
|
||||
"""Schema for the PC collector payload (matched by hostname)."""
|
||||
return {
|
||||
'identityfield': 'hostname',
|
||||
'fields': {
|
||||
'hostname': {'type': 'string', 'required': True},
|
||||
'serialnumber': {'type': 'string'},
|
||||
'currentuser': {'type': 'string'},
|
||||
'lastboottime': {'type': 'string', 'format': 'date-time'},
|
||||
'ipaddress': {'type': 'string'},
|
||||
'installedsoftware': {
|
||||
'type': 'array',
|
||||
'items': {'name': 'string', 'version': 'string'},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def apply_collector_payload(self, payload: Dict) -> Dict:
|
||||
"""Idempotent upsert of a PC from a collector payload (by hostname)."""
|
||||
from datetime import datetime
|
||||
from shopdb.api import Asset, Application, Communication, CommunicationType
|
||||
|
||||
warnings = []
|
||||
hostname = (payload.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
raise ValueError('hostname is required')
|
||||
|
||||
comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
|
||||
if not comp:
|
||||
comp = (Computer.query.join(Asset, Asset.assetid == Computer.assetid)
|
||||
.filter(Asset.assetnumber.ilike(hostname)).first())
|
||||
|
||||
action = 'updated'
|
||||
if not comp:
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
# statusid=1 is the first seeded asset status ("In Use"); a
|
||||
# collector-discovered PC is by definition in use.
|
||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||
statusid=1)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
comp = Computer(assetid=asset.assetid, hostname=hostname)
|
||||
db.session.add(comp)
|
||||
db.session.flush()
|
||||
action = 'created'
|
||||
|
||||
comp.lastreporteddate = datetime.utcnow()
|
||||
if payload.get('lastboottime'):
|
||||
try:
|
||||
comp.lastboottime = datetime.fromisoformat(
|
||||
payload['lastboottime'].replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
warnings.append('lastboottime not parseable')
|
||||
if payload.get('currentuser'):
|
||||
comp.loggedinuser = payload['currentuser']
|
||||
if payload.get('serialnumber') and comp.asset:
|
||||
comp.asset.serialnumber = payload['serialnumber']
|
||||
|
||||
if payload.get('ipaddress'):
|
||||
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
||||
primary = Communication.query.filter_by(
|
||||
assetid=comp.assetid, isprimary=True).first()
|
||||
if primary:
|
||||
primary.ipaddress = payload['ipaddress']
|
||||
elif ip_comtype:
|
||||
db.session.add(Communication(
|
||||
assetid=comp.assetid, comtypeid=ip_comtype.comtypeid,
|
||||
ipaddress=payload['ipaddress'], isprimary=True))
|
||||
|
||||
for app_data in payload.get('installedsoftware', []) or []:
|
||||
name = app_data.get('name')
|
||||
if not name:
|
||||
continue
|
||||
app = Application.query.filter(Application.appname.ilike(name)).first()
|
||||
if not app:
|
||||
warnings.append(f'unknown application: {name}')
|
||||
continue
|
||||
installed = ComputerInstalledApp.query.filter_by(
|
||||
computerid=comp.computerid, appid=app.appid).first()
|
||||
version = app_data.get('version')
|
||||
if installed:
|
||||
installed.installedversion = version
|
||||
installed.isactive = True
|
||||
else:
|
||||
db.session.add(ComputerInstalledApp(
|
||||
computerid=comp.computerid, appid=app.appid,
|
||||
installedversion=version))
|
||||
|
||||
db.session.commit()
|
||||
return {
|
||||
'action': action,
|
||||
'assetid': comp.assetid,
|
||||
'identityvalue': hostname,
|
||||
'warnings': warnings,
|
||||
}
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
self._ensure_computer_types()
|
||||
logger.info("Computers plugin installed")
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
"""Ensure computer asset type exists."""
|
||||
existing = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not existing:
|
||||
at = AssetType(
|
||||
assettype='computer',
|
||||
pluginname='computers',
|
||||
tablename='computers',
|
||||
description='PCs, servers, and workstations',
|
||||
icon='desktop'
|
||||
)
|
||||
db.session.add(at)
|
||||
logger.debug("Created asset type: computer")
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_computer_types(self) -> None:
|
||||
"""Ensure basic computer types exist."""
|
||||
computer_types = [
|
||||
('Shopfloor PC', 'PC located on the shop floor for machine operation', 'desktop'),
|
||||
('Engineer Workstation', 'Engineering workstation for CAD/CAM work', 'laptop'),
|
||||
('CMM PC', 'PC dedicated to CMM operation', 'desktop'),
|
||||
('Server', 'Server system', 'server'),
|
||||
('Kiosk', 'Kiosk or info display PC', 'tv'),
|
||||
('Laptop', 'Laptop computer', 'laptop'),
|
||||
('Virtual Machine', 'Virtual machine', 'cloud'),
|
||||
('Other', 'Other computer type', 'desktop'),
|
||||
]
|
||||
|
||||
for name, description, icon in computer_types:
|
||||
existing = ComputerType.query.filter_by(computertype=name).first()
|
||||
if not existing:
|
||||
ct = ComputerType(
|
||||
computertype=name,
|
||||
description=description,
|
||||
icon=icon
|
||||
)
|
||||
db.session.add(ct)
|
||||
logger.debug(f"Created computer type: {name}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Computers plugin uninstalled")
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@click.group('computers')
|
||||
def computerscli():
|
||||
"""Computers plugin commands."""
|
||||
pass
|
||||
|
||||
@computerscli.command('list-types')
|
||||
def list_types():
|
||||
"""List all computer types."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
types = ComputerType.query.filter_by(isactive=True).all()
|
||||
if not types:
|
||||
click.echo('No computer types found.')
|
||||
return
|
||||
|
||||
click.echo('Computer Types:')
|
||||
for t in types:
|
||||
click.echo(f" [{t.computertypeid}] {t.computertype}")
|
||||
|
||||
@computerscli.command('stats')
|
||||
def stats():
|
||||
"""Show computer statistics."""
|
||||
from flask import current_app
|
||||
from shopdb.api import Asset
|
||||
|
||||
with current_app.app_context():
|
||||
total = db.session.query(Computer).join(Asset).filter(
|
||||
Asset.isactive == True
|
||||
).count()
|
||||
|
||||
click.echo(f"Total active computers: {total}")
|
||||
|
||||
# Shopfloor count
|
||||
shopfloor = db.session.query(Computer).join(Asset).filter(
|
||||
Asset.isactive == True,
|
||||
Computer.isshopfloor == True
|
||||
).count()
|
||||
|
||||
click.echo(f" Shopfloor PCs: {shopfloor}")
|
||||
click.echo(f" Other: {total - shopfloor}")
|
||||
|
||||
@computerscli.command('find')
|
||||
@click.argument('hostname')
|
||||
def find_by_hostname(hostname):
|
||||
"""Find a computer by hostname."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
comp = Computer.query.filter(
|
||||
Computer.hostname.ilike(f'%{hostname}%')
|
||||
).first()
|
||||
|
||||
if not comp:
|
||||
click.echo(f'No computer found matching hostname: {hostname}')
|
||||
return
|
||||
|
||||
click.echo(f'Found: {comp.hostname}')
|
||||
click.echo(f' Asset: {comp.asset.assetnumber}')
|
||||
click.echo(f' Type: {comp.computertype.computertype if comp.computertype else "N/A"}')
|
||||
click.echo(f' OS: {comp.operatingsystem.osname if comp.operatingsystem else "N/A"}')
|
||||
click.echo(f' Logged in: {comp.loggedinuser or "N/A"}')
|
||||
|
||||
return [computerscli]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Return dashboard widget definitions."""
|
||||
return [
|
||||
{
|
||||
'name': 'Computer Status',
|
||||
'component': 'ComputerStatusWidget',
|
||||
'endpoint': '/api/computers/dashboard/summary',
|
||||
'size': 'medium',
|
||||
'position': 6,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
{
|
||||
'name': 'PCs',
|
||||
'icon': 'desktop',
|
||||
'route': '/pcs',
|
||||
'position': 15,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -3,15 +3,7 @@
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Asset, AssetType, Vendor, Model, AuditLog
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
from shopdb.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
|
||||
from ..models import Equipment, EquipmentType
|
||||
|
||||
@@ -160,19 +152,19 @@ def list_equipment():
|
||||
)
|
||||
|
||||
# Equipment type filter
|
||||
if type_id := request.args.get('type_id'):
|
||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
||||
query = query.filter(Equipment.equipmenttypeid == int(type_id))
|
||||
|
||||
# Vendor filter
|
||||
if vendor_id := request.args.get('vendor_id'):
|
||||
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
|
||||
query = query.filter(Equipment.vendorid == int(vendor_id))
|
||||
|
||||
# Location filter
|
||||
if location_id := request.args.get('location_id'):
|
||||
if location_id := request.args.get('locationid', request.args.get('location_id')):
|
||||
query = query.filter(Asset.locationid == int(location_id))
|
||||
|
||||
# Business unit filter
|
||||
if bu_id := request.args.get('businessunit_id'):
|
||||
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
||||
|
||||
# Sorting
|
||||
@@ -282,6 +274,8 @@ def create_equipment():
|
||||
asset = Asset(
|
||||
assetnumber=data['assetnumber'],
|
||||
name=data.get('name'),
|
||||
gaugelabreference=data.get('gaugelabreference'),
|
||||
maintenancereference=data.get('maintenancereference'),
|
||||
serialnumber=data.get('serialnumber'),
|
||||
assettypeid=equipment_type.assettypeid,
|
||||
statusid=data.get('statusid', 1),
|
||||
@@ -357,8 +351,10 @@ def update_equipment(equipment_id: int):
|
||||
changes = {}
|
||||
|
||||
# Update asset fields
|
||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
|
||||
asset_fields = ['assetnumber', 'name', 'gaugelabreference',
|
||||
'maintenancereference', 'serialnumber', 'statusid',
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy',
|
||||
'notes', 'isactive']
|
||||
for key in asset_fields:
|
||||
if key in data:
|
||||
old_val = getattr(asset, key)
|
||||
@@ -442,7 +438,7 @@ def dashboard_summary():
|
||||
).all()
|
||||
|
||||
# Count by status
|
||||
from shopdb.core.models import AssetStatus
|
||||
from shopdb.api import AssetStatus
|
||||
by_status = db.session.query(
|
||||
AssetStatus.status,
|
||||
db.func.count(Equipment.equipmentid)
|
||||
|
||||
@@ -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,133 +1,132 @@
|
||||
"""Equipment plugin models."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models.base import BaseModel
|
||||
|
||||
|
||||
class EquipmentType(BaseModel):
|
||||
"""
|
||||
Equipment type classification.
|
||||
|
||||
Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc.
|
||||
"""
|
||||
__tablename__ = 'equipmenttypes'
|
||||
|
||||
equipmenttypeid = db.Column(db.Integer, primary_key=True)
|
||||
equipmenttype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EquipmentType {self.equipmenttype}>"
|
||||
|
||||
|
||||
class Equipment(BaseModel):
|
||||
"""
|
||||
Equipment-specific extension data.
|
||||
|
||||
Links to core Asset table via assetid.
|
||||
Stores equipment-specific fields like type, model, vendor, etc.
|
||||
"""
|
||||
__tablename__ = 'equipment'
|
||||
|
||||
equipmentid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Link to core asset
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Equipment classification
|
||||
equipmenttypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('equipmenttypes.equipmenttypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Vendor and model
|
||||
vendorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vendors.vendorid'),
|
||||
nullable=True
|
||||
)
|
||||
modelnumberid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('models.modelnumberid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Equipment-specific fields
|
||||
requiresmanualconfig = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Multi-PC machine needs manual configuration'
|
||||
)
|
||||
islocationonly = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Virtual location marker (not actual equipment)'
|
||||
)
|
||||
|
||||
# Maintenance tracking
|
||||
lastmaintenancedate = db.Column(db.DateTime, nullable=True)
|
||||
nextmaintenancedate = db.Column(db.DateTime, nullable=True)
|
||||
maintenanceintervaldays = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# Controller info (for CNC machines)
|
||||
controllervendorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vendors.vendorid'),
|
||||
nullable=True,
|
||||
comment='Controller vendor (e.g., FANUC)'
|
||||
)
|
||||
controllermodelid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('models.modelnumberid'),
|
||||
nullable=True,
|
||||
comment='Controller model (e.g., 31B)'
|
||||
)
|
||||
|
||||
# Relationships
|
||||
asset = db.relationship(
|
||||
'Asset',
|
||||
backref=db.backref('equipment', uselist=False, lazy='joined')
|
||||
)
|
||||
equipmenttype = db.relationship('EquipmentType', backref='equipment')
|
||||
vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items')
|
||||
model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items')
|
||||
controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers')
|
||||
controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_equipment_type', 'equipmenttypeid'),
|
||||
db.Index('idx_equipment_vendor', 'vendorid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Equipment {self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related names."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names
|
||||
if self.equipmenttype:
|
||||
result['equipmenttypename'] = self.equipmenttype.equipmenttype
|
||||
if self.vendor:
|
||||
result['vendorname'] = self.vendor.vendor
|
||||
if self.model:
|
||||
result['modelname'] = self.model.modelnumber
|
||||
if self.model.imageurl:
|
||||
result['imageurl'] = self.model.imageurl
|
||||
|
||||
# Add controller info
|
||||
if self.controllervendor:
|
||||
result['controllervendorname'] = self.controllervendor.vendor
|
||||
if self.controllermodel:
|
||||
result['controllermodelname'] = self.controllermodel.modelnumber
|
||||
|
||||
return result
|
||||
"""Equipment plugin models."""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class EquipmentType(BaseModel):
|
||||
"""
|
||||
Equipment type classification.
|
||||
|
||||
Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc.
|
||||
"""
|
||||
__tablename__ = 'equipmenttypes'
|
||||
|
||||
equipmenttypeid = db.Column(db.Integer, primary_key=True)
|
||||
equipmenttype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EquipmentType {self.equipmenttype}>"
|
||||
|
||||
|
||||
class Equipment(BaseModel):
|
||||
"""
|
||||
Equipment-specific extension data.
|
||||
|
||||
Links to core Asset table via assetid.
|
||||
Stores equipment-specific fields like type, model, vendor, etc.
|
||||
"""
|
||||
__tablename__ = 'equipment'
|
||||
|
||||
equipmentid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Link to core asset
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Equipment classification
|
||||
equipmenttypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('equipmenttypes.equipmenttypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Vendor and model
|
||||
vendorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vendors.vendorid'),
|
||||
nullable=True
|
||||
)
|
||||
modelnumberid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('models.modelnumberid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Equipment-specific fields
|
||||
requiresmanualconfig = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Multi-PC machine needs manual configuration'
|
||||
)
|
||||
islocationonly = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Virtual location marker (not actual equipment)'
|
||||
)
|
||||
|
||||
# Maintenance tracking
|
||||
lastmaintenancedate = db.Column(db.DateTime, nullable=True)
|
||||
nextmaintenancedate = db.Column(db.DateTime, nullable=True)
|
||||
maintenanceintervaldays = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# Controller info (for CNC machines)
|
||||
controllervendorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vendors.vendorid'),
|
||||
nullable=True,
|
||||
comment='Controller vendor (e.g., FANUC)'
|
||||
)
|
||||
controllermodelid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('models.modelnumberid'),
|
||||
nullable=True,
|
||||
comment='Controller model (e.g., 31B)'
|
||||
)
|
||||
|
||||
# Relationships
|
||||
asset = db.relationship(
|
||||
'Asset',
|
||||
backref=db.backref('equipment', uselist=False, lazy='joined')
|
||||
)
|
||||
equipmenttype = db.relationship('EquipmentType', backref='equipment')
|
||||
vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items')
|
||||
model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items')
|
||||
controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers')
|
||||
controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_equipment_type', 'equipmenttypeid'),
|
||||
db.Index('idx_equipment_vendor', 'vendorid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Equipment {self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related names."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names
|
||||
if self.equipmenttype:
|
||||
result['equipmenttypename'] = self.equipmenttype.equipmenttype
|
||||
if self.vendor:
|
||||
result['vendorname'] = self.vendor.vendor
|
||||
if self.model:
|
||||
result['modelname'] = self.model.modelnumber
|
||||
if self.model.imageurl:
|
||||
result['imageurl'] = self.model.imageurl
|
||||
|
||||
# Add controller info
|
||||
if self.controllervendor:
|
||||
result['controllervendorname'] = self.controllervendor.vendor
|
||||
if self.controllermodel:
|
||||
result['controllermodelname'] = self.controllermodel.modelnumber
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,220 +1,219 @@
|
||||
"""Equipment plugin main class."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import AssetType, AssetStatus
|
||||
|
||||
from .models import Equipment, EquipmentType
|
||||
from .api import equipment_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EquipmentPlugin(BasePlugin):
|
||||
"""
|
||||
Equipment plugin - manages manufacturing equipment assets.
|
||||
|
||||
Equipment includes CNCs, CMMs, lathes, grinders, EDMs, part markers, etc.
|
||||
Uses the new Asset architecture with Equipment extension table.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifestpath = Path(__file__).parent / 'manifest.json'
|
||||
if manifestpath.exists():
|
||||
with open(manifestpath, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'equipment'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Equipment management for manufacturing assets'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/equipment'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
return equipment_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [Equipment, EquipmentType]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Equipment plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
self._ensure_asset_statuses()
|
||||
self._ensure_equipment_types()
|
||||
logger.info("Equipment plugin installed")
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
"""Ensure equipment asset type exists."""
|
||||
existing = AssetType.query.filter_by(assettype='equipment').first()
|
||||
if not existing:
|
||||
at = AssetType(
|
||||
assettype='equipment',
|
||||
pluginname='equipment',
|
||||
tablename='equipment',
|
||||
description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)',
|
||||
icon='cog'
|
||||
)
|
||||
db.session.add(at)
|
||||
logger.debug("Created asset type: equipment")
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_asset_statuses(self) -> None:
|
||||
"""Ensure standard asset statuses exist."""
|
||||
statuses = [
|
||||
('In Use', 'Asset is currently in use', '#28a745'),
|
||||
('Spare', 'Spare/backup asset', '#17a2b8'),
|
||||
('Retired', 'Asset has been retired', '#6c757d'),
|
||||
('Maintenance', 'Asset is under maintenance', '#ffc107'),
|
||||
('Decommissioned', 'Asset has been decommissioned', '#dc3545'),
|
||||
]
|
||||
|
||||
for name, description, color in statuses:
|
||||
existing = AssetStatus.query.filter_by(status=name).first()
|
||||
if not existing:
|
||||
s = AssetStatus(
|
||||
status=name,
|
||||
description=description,
|
||||
color=color
|
||||
)
|
||||
db.session.add(s)
|
||||
logger.debug(f"Created asset status: {name}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_equipment_types(self) -> None:
|
||||
"""Ensure basic equipment types exist."""
|
||||
equipment_types = [
|
||||
('CNC', 'Computer Numerical Control machine', 'cnc'),
|
||||
('CMM', 'Coordinate Measuring Machine', 'cmm'),
|
||||
('Lathe', 'Lathe machine', 'lathe'),
|
||||
('Grinder', 'Grinding machine', 'grinder'),
|
||||
('EDM', 'Electrical Discharge Machine', 'edm'),
|
||||
('Part Marker', 'Part marking/engraving equipment', 'marker'),
|
||||
('Mill', 'Milling machine', 'mill'),
|
||||
('Press', 'Press machine', 'press'),
|
||||
('Robot', 'Industrial robot', 'robot'),
|
||||
('Other', 'Other equipment type', 'cog'),
|
||||
]
|
||||
|
||||
for name, description, icon in equipment_types:
|
||||
existing = EquipmentType.query.filter_by(equipmenttype=name).first()
|
||||
if not existing:
|
||||
et = EquipmentType(
|
||||
equipmenttype=name,
|
||||
description=description,
|
||||
icon=icon
|
||||
)
|
||||
db.session.add(et)
|
||||
logger.debug(f"Created equipment type: {name}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Equipment plugin uninstalled")
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@click.group('equipment')
|
||||
def equipmentcli():
|
||||
"""Equipment plugin commands."""
|
||||
pass
|
||||
|
||||
@equipmentcli.command('list-types')
|
||||
def list_types():
|
||||
"""List all equipment types."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
types = EquipmentType.query.filter_by(isactive=True).all()
|
||||
if not types:
|
||||
click.echo('No equipment types found.')
|
||||
return
|
||||
|
||||
click.echo('Equipment Types:')
|
||||
for t in types:
|
||||
click.echo(f" [{t.equipmenttypeid}] {t.equipmenttype}")
|
||||
|
||||
@equipmentcli.command('stats')
|
||||
def stats():
|
||||
"""Show equipment statistics."""
|
||||
from flask import current_app
|
||||
from shopdb.core.models import Asset
|
||||
|
||||
with current_app.app_context():
|
||||
total = db.session.query(Equipment).join(Asset).filter(
|
||||
Asset.isactive == True
|
||||
).count()
|
||||
|
||||
click.echo(f"Total active equipment: {total}")
|
||||
|
||||
# By type
|
||||
by_type = db.session.query(
|
||||
EquipmentType.equipmenttype,
|
||||
db.func.count(Equipment.equipmentid)
|
||||
).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
|
||||
).join(Asset, Asset.assetid == Equipment.assetid
|
||||
).filter(Asset.isactive == True
|
||||
).group_by(EquipmentType.equipmenttype
|
||||
).all()
|
||||
|
||||
if by_type:
|
||||
click.echo("\nBy Type:")
|
||||
for t, c in by_type:
|
||||
click.echo(f" {t}: {c}")
|
||||
|
||||
return [equipmentcli]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Return dashboard widget definitions."""
|
||||
return [
|
||||
{
|
||||
'name': 'Equipment Status',
|
||||
'component': 'EquipmentStatusWidget',
|
||||
'endpoint': '/api/equipment/dashboard/summary',
|
||||
'size': 'medium',
|
||||
'position': 5,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
{
|
||||
'name': 'Equipment',
|
||||
'icon': 'cog',
|
||||
'route': '/machines',
|
||||
'position': 10,
|
||||
},
|
||||
]
|
||||
"""Equipment plugin main class."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, AssetType, AssetStatus
|
||||
|
||||
from .models import Equipment, EquipmentType
|
||||
from .api import equipment_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EquipmentPlugin(BasePlugin):
|
||||
"""
|
||||
Equipment plugin - manages manufacturing equipment assets.
|
||||
|
||||
Equipment includes CNCs, CMMs, lathes, grinders, EDMs, part markers, etc.
|
||||
Uses the new Asset architecture with Equipment extension table.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifestpath = Path(__file__).parent / 'manifest.json'
|
||||
if manifestpath.exists():
|
||||
with open(manifestpath, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'equipment'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Equipment management for manufacturing assets'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/equipment'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
return equipment_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [Equipment, EquipmentType]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Equipment plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
self._ensure_asset_statuses()
|
||||
self._ensure_equipment_types()
|
||||
logger.info("Equipment plugin installed")
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
"""Ensure equipment asset type exists."""
|
||||
existing = AssetType.query.filter_by(assettype='equipment').first()
|
||||
if not existing:
|
||||
at = AssetType(
|
||||
assettype='equipment',
|
||||
pluginname='equipment',
|
||||
tablename='equipment',
|
||||
description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)',
|
||||
icon='cog'
|
||||
)
|
||||
db.session.add(at)
|
||||
logger.debug("Created asset type: equipment")
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_asset_statuses(self) -> None:
|
||||
"""Ensure standard asset statuses exist."""
|
||||
statuses = [
|
||||
('In Use', 'Asset is currently in use', '#28a745'),
|
||||
('Spare', 'Spare/backup asset', '#17a2b8'),
|
||||
('Retired', 'Asset has been retired', '#6c757d'),
|
||||
('Maintenance', 'Asset is under maintenance', '#ffc107'),
|
||||
('Decommissioned', 'Asset has been decommissioned', '#dc3545'),
|
||||
]
|
||||
|
||||
for name, description, color in statuses:
|
||||
existing = AssetStatus.query.filter_by(status=name).first()
|
||||
if not existing:
|
||||
s = AssetStatus(
|
||||
status=name,
|
||||
description=description,
|
||||
color=color
|
||||
)
|
||||
db.session.add(s)
|
||||
logger.debug(f"Created asset status: {name}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_equipment_types(self) -> None:
|
||||
"""Ensure basic equipment types exist."""
|
||||
equipment_types = [
|
||||
('CNC', 'Computer Numerical Control machine', 'cnc'),
|
||||
('CMM', 'Coordinate Measuring Machine', 'cmm'),
|
||||
('Lathe', 'Lathe machine', 'lathe'),
|
||||
('Grinder', 'Grinding machine', 'grinder'),
|
||||
('EDM', 'Electrical Discharge Machine', 'edm'),
|
||||
('Part Marker', 'Part marking/engraving equipment', 'marker'),
|
||||
('Mill', 'Milling machine', 'mill'),
|
||||
('Press', 'Press machine', 'press'),
|
||||
('Robot', 'Industrial robot', 'robot'),
|
||||
('Other', 'Other equipment type', 'cog'),
|
||||
]
|
||||
|
||||
for name, description, icon in equipment_types:
|
||||
existing = EquipmentType.query.filter_by(equipmenttype=name).first()
|
||||
if not existing:
|
||||
et = EquipmentType(
|
||||
equipmenttype=name,
|
||||
description=description,
|
||||
icon=icon
|
||||
)
|
||||
db.session.add(et)
|
||||
logger.debug(f"Created equipment type: {name}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Equipment plugin uninstalled")
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@click.group('equipment')
|
||||
def equipmentcli():
|
||||
"""Equipment plugin commands."""
|
||||
pass
|
||||
|
||||
@equipmentcli.command('list-types')
|
||||
def list_types():
|
||||
"""List all equipment types."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
types = EquipmentType.query.filter_by(isactive=True).all()
|
||||
if not types:
|
||||
click.echo('No equipment types found.')
|
||||
return
|
||||
|
||||
click.echo('Equipment Types:')
|
||||
for t in types:
|
||||
click.echo(f" [{t.equipmenttypeid}] {t.equipmenttype}")
|
||||
|
||||
@equipmentcli.command('stats')
|
||||
def stats():
|
||||
"""Show equipment statistics."""
|
||||
from flask import current_app
|
||||
from shopdb.api import Asset
|
||||
|
||||
with current_app.app_context():
|
||||
total = db.session.query(Equipment).join(Asset).filter(
|
||||
Asset.isactive == True
|
||||
).count()
|
||||
|
||||
click.echo(f"Total active equipment: {total}")
|
||||
|
||||
# By type
|
||||
by_type = db.session.query(
|
||||
EquipmentType.equipmenttype,
|
||||
db.func.count(Equipment.equipmentid)
|
||||
).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
|
||||
).join(Asset, Asset.assetid == Equipment.assetid
|
||||
).filter(Asset.isactive == True
|
||||
).group_by(EquipmentType.equipmenttype
|
||||
).all()
|
||||
|
||||
if by_type:
|
||||
click.echo("\nBy Type:")
|
||||
for t, c in by_type:
|
||||
click.echo(f" {t}: {c}")
|
||||
|
||||
return [equipmentcli]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Return dashboard widget definitions."""
|
||||
return [
|
||||
{
|
||||
'name': 'Equipment Status',
|
||||
'component': 'EquipmentStatusWidget',
|
||||
'endpoint': '/api/equipment/dashboard/summary',
|
||||
'size': 'medium',
|
||||
'position': 5,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
{
|
||||
'name': 'Equipment',
|
||||
'icon': 'cog',
|
||||
'route': '/machines',
|
||||
'position': 10,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -3,15 +3,7 @@
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Asset, AssetType, Vendor, AuditLog
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
from shopdb.api import db, Asset, AssetType, Vendor, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
|
||||
from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
|
||||
|
||||
@@ -163,19 +155,19 @@ def list_network_devices():
|
||||
)
|
||||
|
||||
# Type filter
|
||||
if type_id := request.args.get('type_id'):
|
||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
||||
query = query.filter(NetworkDevice.networkdevicetypeid == int(type_id))
|
||||
|
||||
# Vendor filter
|
||||
if vendor_id := request.args.get('vendor_id'):
|
||||
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
|
||||
query = query.filter(NetworkDevice.vendorid == int(vendor_id))
|
||||
|
||||
# Location filter
|
||||
if location_id := request.args.get('location_id'):
|
||||
if location_id := request.args.get('locationid', request.args.get('location_id')):
|
||||
query = query.filter(Asset.locationid == int(location_id))
|
||||
|
||||
# Business unit filter
|
||||
if bu_id := request.args.get('businessunit_id'):
|
||||
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
||||
|
||||
# PoE filter
|
||||
@@ -207,7 +199,7 @@ def list_network_devices():
|
||||
data = []
|
||||
for netdev in items:
|
||||
item = netdev.asset.to_dict() if netdev.asset else {}
|
||||
item['network_device'] = netdev.to_dict()
|
||||
item['networkdevice'] = netdev.to_dict()
|
||||
data.append(item)
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
@@ -324,6 +316,8 @@ def create_network_device():
|
||||
assetnumber=data['assetnumber'],
|
||||
name=data.get('name'),
|
||||
serialnumber=data.get('serialnumber'),
|
||||
gaugelabreference=data.get('gaugelabreference'),
|
||||
maintenancereference=data.get('maintenancereference'),
|
||||
assettypeid=network_type.assettypeid,
|
||||
statusid=data.get('statusid', 1),
|
||||
locationid=data.get('locationid'),
|
||||
@@ -406,7 +400,8 @@ def update_network_device(device_id: int):
|
||||
changes = {}
|
||||
|
||||
# Update asset fields
|
||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
|
||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
||||
'maintenancereference', 'statusid',
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
|
||||
for key in asset_fields:
|
||||
if key in data:
|
||||
|
||||
@@ -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,121 +1,120 @@
|
||||
"""Network device plugin models."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models.base import BaseModel
|
||||
|
||||
|
||||
class NetworkDeviceType(BaseModel):
|
||||
"""
|
||||
Network device type classification.
|
||||
|
||||
Examples: Switch, Router, Access Point, Camera, IDF, Firewall, etc.
|
||||
"""
|
||||
__tablename__ = 'networkdevicetypes'
|
||||
|
||||
networkdevicetypeid = db.Column(db.Integer, primary_key=True)
|
||||
networkdevicetype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NetworkDeviceType {self.networkdevicetype}>"
|
||||
|
||||
|
||||
class NetworkDevice(BaseModel):
|
||||
"""
|
||||
Network device-specific extension data.
|
||||
|
||||
Links to core Asset table via assetid.
|
||||
Stores network device-specific fields like hostname, firmware, ports, etc.
|
||||
"""
|
||||
__tablename__ = 'networkdevices'
|
||||
|
||||
networkdeviceid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Link to core asset
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Network device classification
|
||||
networkdevicetypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('networkdevicetypes.networkdevicetypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Vendor
|
||||
vendorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vendors.vendorid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Network identity
|
||||
hostname = db.Column(
|
||||
db.String(100),
|
||||
index=True,
|
||||
comment='Network hostname'
|
||||
)
|
||||
|
||||
# Firmware/software version
|
||||
firmwareversion = db.Column(db.String(100), nullable=True)
|
||||
|
||||
# Physical characteristics
|
||||
portcount = db.Column(
|
||||
db.Integer,
|
||||
nullable=True,
|
||||
comment='Number of ports (for switches)'
|
||||
)
|
||||
|
||||
# Features
|
||||
ispoe = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Power over Ethernet capable'
|
||||
)
|
||||
ismanaged = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Managed device (SNMP, web interface, etc.)'
|
||||
)
|
||||
|
||||
# For IDF/closet locations
|
||||
rackunit = db.Column(
|
||||
db.String(20),
|
||||
nullable=True,
|
||||
comment='Rack unit position (e.g., U1, U5)'
|
||||
)
|
||||
|
||||
# Relationships
|
||||
asset = db.relationship(
|
||||
'Asset',
|
||||
backref=db.backref('network_device', uselist=False, lazy='joined')
|
||||
)
|
||||
networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices')
|
||||
vendor = db.relationship('Vendor', backref='network_devices')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_netdev_type', 'networkdevicetypeid'),
|
||||
db.Index('idx_netdev_hostname', 'hostname'),
|
||||
db.Index('idx_netdev_vendor', 'vendorid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NetworkDevice {self.hostname or self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related names."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names
|
||||
if self.networkdevicetype:
|
||||
result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype
|
||||
if self.vendor:
|
||||
result['vendorname'] = self.vendor.vendor
|
||||
|
||||
return result
|
||||
"""Network device plugin models."""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class NetworkDeviceType(BaseModel):
|
||||
"""
|
||||
Network device type classification.
|
||||
|
||||
Examples: Switch, Router, Access Point, Camera, IDF, Firewall, etc.
|
||||
"""
|
||||
__tablename__ = 'networkdevicetypes'
|
||||
|
||||
networkdevicetypeid = db.Column(db.Integer, primary_key=True)
|
||||
networkdevicetype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NetworkDeviceType {self.networkdevicetype}>"
|
||||
|
||||
|
||||
class NetworkDevice(BaseModel):
|
||||
"""
|
||||
Network device-specific extension data.
|
||||
|
||||
Links to core Asset table via assetid.
|
||||
Stores network device-specific fields like hostname, firmware, ports, etc.
|
||||
"""
|
||||
__tablename__ = 'networkdevices'
|
||||
|
||||
networkdeviceid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Link to core asset
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Network device classification
|
||||
networkdevicetypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('networkdevicetypes.networkdevicetypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Vendor
|
||||
vendorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vendors.vendorid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Network identity
|
||||
hostname = db.Column(
|
||||
db.String(100),
|
||||
index=True,
|
||||
comment='Network hostname'
|
||||
)
|
||||
|
||||
# Firmware/software version
|
||||
firmwareversion = db.Column(db.String(100), nullable=True)
|
||||
|
||||
# Physical characteristics
|
||||
portcount = db.Column(
|
||||
db.Integer,
|
||||
nullable=True,
|
||||
comment='Number of ports (for switches)'
|
||||
)
|
||||
|
||||
# Features
|
||||
ispoe = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Power over Ethernet capable'
|
||||
)
|
||||
ismanaged = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Managed device (SNMP, web interface, etc.)'
|
||||
)
|
||||
|
||||
# For IDF/closet locations
|
||||
rackunit = db.Column(
|
||||
db.String(20),
|
||||
nullable=True,
|
||||
comment='Rack unit position (e.g., U1, U5)'
|
||||
)
|
||||
|
||||
# Relationships
|
||||
asset = db.relationship(
|
||||
'Asset',
|
||||
backref=db.backref('network_device', uselist=False, lazy='joined')
|
||||
)
|
||||
networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices')
|
||||
vendor = db.relationship('Vendor', backref='network_devices')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_netdev_type', 'networkdevicetypeid'),
|
||||
db.Index('idx_netdev_hostname', 'hostname'),
|
||||
db.Index('idx_netdev_vendor', 'vendorid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NetworkDevice {self.hostname or self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related names."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names
|
||||
if self.networkdevicetype:
|
||||
result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype
|
||||
if self.vendor:
|
||||
result['vendorname'] = self.vendor.vendor
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,146 +1,145 @@
|
||||
"""Subnet and VLAN models for network plugin."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models.base import BaseModel
|
||||
|
||||
|
||||
class VLAN(BaseModel):
|
||||
"""
|
||||
VLAN definition.
|
||||
|
||||
Represents a virtual LAN for network segmentation.
|
||||
"""
|
||||
__tablename__ = 'vlans'
|
||||
|
||||
vlanid = db.Column(db.Integer, primary_key=True)
|
||||
vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number')
|
||||
name = db.Column(db.String(100), nullable=False, comment='VLAN name')
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Optional classification
|
||||
vlantype = db.Column(
|
||||
db.String(50),
|
||||
nullable=True,
|
||||
comment='Type: data, voice, management, guest, etc.'
|
||||
)
|
||||
|
||||
# Relationships
|
||||
subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_vlan_number', 'vlannumber'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<VLAN {self.vlannumber} - {self.name}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary."""
|
||||
result = super().to_dict()
|
||||
result['subnetcount'] = self.subnets.count() if self.subnets else 0
|
||||
return result
|
||||
|
||||
|
||||
class Subnet(BaseModel):
|
||||
"""
|
||||
Subnet/IP network definition.
|
||||
|
||||
Represents an IP subnet with optional VLAN association.
|
||||
"""
|
||||
__tablename__ = 'subnets'
|
||||
|
||||
subnetid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Network definition
|
||||
cidr = db.Column(
|
||||
db.String(18),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
comment='CIDR notation (e.g., 10.1.1.0/24)'
|
||||
)
|
||||
name = db.Column(db.String(100), nullable=False, comment='Subnet name')
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Network details
|
||||
gatewayip = db.Column(
|
||||
db.String(15),
|
||||
nullable=True,
|
||||
comment='Default gateway IP address'
|
||||
)
|
||||
subnetmask = db.Column(
|
||||
db.String(15),
|
||||
nullable=True,
|
||||
comment='Subnet mask (e.g., 255.255.255.0)'
|
||||
)
|
||||
networkaddress = db.Column(
|
||||
db.String(15),
|
||||
nullable=True,
|
||||
comment='Network address (e.g., 10.1.1.0)'
|
||||
)
|
||||
broadcastaddress = db.Column(
|
||||
db.String(15),
|
||||
nullable=True,
|
||||
comment='Broadcast address (e.g., 10.1.1.255)'
|
||||
)
|
||||
|
||||
# VLAN association
|
||||
vlanid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vlans.vlanid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Classification
|
||||
subnettype = db.Column(
|
||||
db.String(50),
|
||||
nullable=True,
|
||||
comment='Type: production, development, management, dmz, etc.'
|
||||
)
|
||||
|
||||
# Location association
|
||||
locationid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('locations.locationid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# DHCP settings
|
||||
dhcpenabled = db.Column(db.Boolean, default=True, comment='DHCP enabled for this subnet')
|
||||
dhcprangestart = db.Column(db.String(15), nullable=True, comment='DHCP range start IP')
|
||||
dhcprangeend = db.Column(db.String(15), nullable=True, comment='DHCP range end IP')
|
||||
|
||||
# DNS settings
|
||||
dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server')
|
||||
dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server')
|
||||
|
||||
# Relationships
|
||||
location = db.relationship('Location', backref='subnets')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_subnet_cidr', 'cidr'),
|
||||
db.Index('idx_subnet_vlan', 'vlanid'),
|
||||
db.Index('idx_subnet_location', 'locationid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Subnet {self.cidr} - {self.name}>"
|
||||
|
||||
@property
|
||||
def vlan_number(self):
|
||||
"""Get the VLAN number."""
|
||||
return self.vlan.vlannumber if self.vlan else None
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related data."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add VLAN info
|
||||
if self.vlan:
|
||||
result['vlannumber'] = self.vlan.vlannumber
|
||||
result['vlanname'] = self.vlan.name
|
||||
|
||||
# Add location info
|
||||
if self.location:
|
||||
result['locationname'] = self.location.locationname
|
||||
|
||||
return result
|
||||
"""Subnet and VLAN models for network plugin."""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class VLAN(BaseModel):
|
||||
"""
|
||||
VLAN definition.
|
||||
|
||||
Represents a virtual LAN for network segmentation.
|
||||
"""
|
||||
__tablename__ = 'vlans'
|
||||
|
||||
vlanid = db.Column(db.Integer, primary_key=True)
|
||||
vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number')
|
||||
name = db.Column(db.String(100), nullable=False, comment='VLAN name')
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Optional classification
|
||||
vlantype = db.Column(
|
||||
db.String(50),
|
||||
nullable=True,
|
||||
comment='Type: data, voice, management, guest, etc.'
|
||||
)
|
||||
|
||||
# Relationships
|
||||
subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_vlan_number', 'vlannumber'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<VLAN {self.vlannumber} - {self.name}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary."""
|
||||
result = super().to_dict()
|
||||
result['subnetcount'] = self.subnets.count() if self.subnets else 0
|
||||
return result
|
||||
|
||||
|
||||
class Subnet(BaseModel):
|
||||
"""
|
||||
Subnet/IP network definition.
|
||||
|
||||
Represents an IP subnet with optional VLAN association.
|
||||
"""
|
||||
__tablename__ = 'subnets'
|
||||
|
||||
subnetid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Network definition
|
||||
cidr = db.Column(
|
||||
db.String(18),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
comment='CIDR notation (e.g., 10.1.1.0/24)'
|
||||
)
|
||||
name = db.Column(db.String(100), nullable=False, comment='Subnet name')
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Network details
|
||||
gatewayip = db.Column(
|
||||
db.String(15),
|
||||
nullable=True,
|
||||
comment='Default gateway IP address'
|
||||
)
|
||||
subnetmask = db.Column(
|
||||
db.String(15),
|
||||
nullable=True,
|
||||
comment='Subnet mask (e.g., 255.255.255.0)'
|
||||
)
|
||||
networkaddress = db.Column(
|
||||
db.String(15),
|
||||
nullable=True,
|
||||
comment='Network address (e.g., 10.1.1.0)'
|
||||
)
|
||||
broadcastaddress = db.Column(
|
||||
db.String(15),
|
||||
nullable=True,
|
||||
comment='Broadcast address (e.g., 10.1.1.255)'
|
||||
)
|
||||
|
||||
# VLAN association
|
||||
vlanid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vlans.vlanid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Classification
|
||||
subnettype = db.Column(
|
||||
db.String(50),
|
||||
nullable=True,
|
||||
comment='Type: production, development, management, dmz, etc.'
|
||||
)
|
||||
|
||||
# Location association
|
||||
locationid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('locations.locationid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# DHCP settings
|
||||
dhcpenabled = db.Column(db.Boolean, default=True, comment='DHCP enabled for this subnet')
|
||||
dhcprangestart = db.Column(db.String(15), nullable=True, comment='DHCP range start IP')
|
||||
dhcprangeend = db.Column(db.String(15), nullable=True, comment='DHCP range end IP')
|
||||
|
||||
# DNS settings
|
||||
dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server')
|
||||
dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server')
|
||||
|
||||
# Relationships
|
||||
location = db.relationship('Location', backref='subnets')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_subnet_cidr', 'cidr'),
|
||||
db.Index('idx_subnet_vlan', 'vlanid'),
|
||||
db.Index('idx_subnet_location', 'locationid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Subnet {self.cidr} - {self.name}>"
|
||||
|
||||
@property
|
||||
def vlan_number(self):
|
||||
"""Get the VLAN number."""
|
||||
return self.vlan.vlannumber if self.vlan else None
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related data."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add VLAN info
|
||||
if self.vlan:
|
||||
result['vlannumber'] = self.vlan.vlannumber
|
||||
result['vlanname'] = self.vlan.name
|
||||
|
||||
# Add location info
|
||||
if self.location:
|
||||
result['locationname'] = self.location.locationname
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,217 +1,216 @@
|
||||
"""Network plugin main class."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import AssetType
|
||||
|
||||
from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
|
||||
from .api import network_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NetworkPlugin(BasePlugin):
|
||||
"""
|
||||
Network plugin - manages network device assets.
|
||||
|
||||
Network devices include switches, routers, access points, cameras, IDFs, etc.
|
||||
Uses the new Asset architecture with NetworkDevice extension table.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifestpath = Path(__file__).parent / 'manifest.json'
|
||||
if manifestpath.exists():
|
||||
with open(manifestpath, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'network'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Network device management for switches, APs, and cameras'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/network'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
return network_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [NetworkDevice, NetworkDeviceType, Subnet, VLAN]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Network plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
self._ensure_network_device_types()
|
||||
logger.info("Network plugin installed")
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
"""Ensure network_device asset type exists."""
|
||||
existing = AssetType.query.filter_by(assettype='network_device').first()
|
||||
if not existing:
|
||||
at = AssetType(
|
||||
assettype='network_device',
|
||||
pluginname='network',
|
||||
tablename='networkdevices',
|
||||
description='Network infrastructure devices (switches, APs, cameras, etc.)',
|
||||
icon='network-wired'
|
||||
)
|
||||
db.session.add(at)
|
||||
logger.debug("Created asset type: network_device")
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_network_device_types(self) -> None:
|
||||
"""Ensure basic network device types exist."""
|
||||
device_types = [
|
||||
('Switch', 'Network switch', 'network-wired'),
|
||||
('Router', 'Network router', 'router'),
|
||||
('Access Point', 'Wireless access point', 'wifi'),
|
||||
('Firewall', 'Network firewall', 'shield'),
|
||||
('Camera', 'IP camera', 'video'),
|
||||
('IDF', 'Intermediate Distribution Frame/closet', 'box'),
|
||||
('MDF', 'Main Distribution Frame', 'building'),
|
||||
('Patch Panel', 'Patch panel', 'th'),
|
||||
('UPS', 'Uninterruptible power supply', 'battery'),
|
||||
('Other', 'Other network device', 'network-wired'),
|
||||
]
|
||||
|
||||
for name, description, icon in device_types:
|
||||
existing = NetworkDeviceType.query.filter_by(networkdevicetype=name).first()
|
||||
if not existing:
|
||||
ndt = NetworkDeviceType(
|
||||
networkdevicetype=name,
|
||||
description=description,
|
||||
icon=icon
|
||||
)
|
||||
db.session.add(ndt)
|
||||
logger.debug(f"Created network device type: {name}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Network plugin uninstalled")
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@click.group('network')
|
||||
def networkcli():
|
||||
"""Network plugin commands."""
|
||||
pass
|
||||
|
||||
@networkcli.command('list-types')
|
||||
def list_types():
|
||||
"""List all network device types."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
types = NetworkDeviceType.query.filter_by(isactive=True).all()
|
||||
if not types:
|
||||
click.echo('No network device types found.')
|
||||
return
|
||||
|
||||
click.echo('Network Device Types:')
|
||||
for t in types:
|
||||
click.echo(f" [{t.networkdevicetypeid}] {t.networkdevicetype}")
|
||||
|
||||
@networkcli.command('stats')
|
||||
def stats():
|
||||
"""Show network device statistics."""
|
||||
from flask import current_app
|
||||
from shopdb.core.models import Asset
|
||||
|
||||
with current_app.app_context():
|
||||
total = db.session.query(NetworkDevice).join(Asset).filter(
|
||||
Asset.isactive == True
|
||||
).count()
|
||||
|
||||
click.echo(f"Total active network devices: {total}")
|
||||
|
||||
# By type
|
||||
by_type = db.session.query(
|
||||
NetworkDeviceType.networkdevicetype,
|
||||
db.func.count(NetworkDevice.networkdeviceid)
|
||||
).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid
|
||||
).join(Asset, Asset.assetid == NetworkDevice.assetid
|
||||
).filter(Asset.isactive == True
|
||||
).group_by(NetworkDeviceType.networkdevicetype
|
||||
).all()
|
||||
|
||||
if by_type:
|
||||
click.echo("\nBy Type:")
|
||||
for t, c in by_type:
|
||||
click.echo(f" {t}: {c}")
|
||||
|
||||
@networkcli.command('find')
|
||||
@click.argument('hostname')
|
||||
def find_by_hostname(hostname):
|
||||
"""Find a network device by hostname."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
netdev = NetworkDevice.query.filter(
|
||||
NetworkDevice.hostname.ilike(f'%{hostname}%')
|
||||
).first()
|
||||
|
||||
if not netdev:
|
||||
click.echo(f'No network device found matching hostname: {hostname}')
|
||||
return
|
||||
|
||||
click.echo(f'Found: {netdev.hostname}')
|
||||
click.echo(f' Asset: {netdev.asset.assetnumber}')
|
||||
click.echo(f' Type: {netdev.networkdevicetype.networkdevicetype if netdev.networkdevicetype else "N/A"}')
|
||||
click.echo(f' Firmware: {netdev.firmwareversion or "N/A"}')
|
||||
click.echo(f' PoE: {"Yes" if netdev.ispoe else "No"}')
|
||||
|
||||
return [networkcli]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Return dashboard widget definitions."""
|
||||
return [
|
||||
{
|
||||
'name': 'Network Status',
|
||||
'component': 'NetworkStatusWidget',
|
||||
'endpoint': '/api/network/dashboard/summary',
|
||||
'size': 'medium',
|
||||
'position': 7,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
{
|
||||
'name': 'Network',
|
||||
'icon': 'network-wired',
|
||||
'route': '/network',
|
||||
'position': 18,
|
||||
},
|
||||
]
|
||||
"""Network plugin main class."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, AssetType
|
||||
|
||||
from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
|
||||
from .api import network_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NetworkPlugin(BasePlugin):
|
||||
"""
|
||||
Network plugin - manages network device assets.
|
||||
|
||||
Network devices include switches, routers, access points, cameras, IDFs, etc.
|
||||
Uses the new Asset architecture with NetworkDevice extension table.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifestpath = Path(__file__).parent / 'manifest.json'
|
||||
if manifestpath.exists():
|
||||
with open(manifestpath, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'network'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Network device management for switches, APs, and cameras'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/network'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
return network_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [NetworkDevice, NetworkDeviceType, Subnet, VLAN]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Network plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
self._ensure_network_device_types()
|
||||
logger.info("Network plugin installed")
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
"""Ensure network_device asset type exists."""
|
||||
existing = AssetType.query.filter_by(assettype='network_device').first()
|
||||
if not existing:
|
||||
at = AssetType(
|
||||
assettype='network_device',
|
||||
pluginname='network',
|
||||
tablename='networkdevices',
|
||||
description='Network infrastructure devices (switches, APs, cameras, etc.)',
|
||||
icon='network-wired'
|
||||
)
|
||||
db.session.add(at)
|
||||
logger.debug("Created asset type: network_device")
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_network_device_types(self) -> None:
|
||||
"""Ensure basic network device types exist."""
|
||||
device_types = [
|
||||
('Switch', 'Network switch', 'network-wired'),
|
||||
('Router', 'Network router', 'router'),
|
||||
('Access Point', 'Wireless access point', 'wifi'),
|
||||
('Firewall', 'Network firewall', 'shield'),
|
||||
('Camera', 'IP camera', 'video'),
|
||||
('IDF', 'Intermediate Distribution Frame/closet', 'box'),
|
||||
('MDF', 'Main Distribution Frame', 'building'),
|
||||
('Patch Panel', 'Patch panel', 'th'),
|
||||
('UPS', 'Uninterruptible power supply', 'battery'),
|
||||
('Other', 'Other network device', 'network-wired'),
|
||||
]
|
||||
|
||||
for name, description, icon in device_types:
|
||||
existing = NetworkDeviceType.query.filter_by(networkdevicetype=name).first()
|
||||
if not existing:
|
||||
ndt = NetworkDeviceType(
|
||||
networkdevicetype=name,
|
||||
description=description,
|
||||
icon=icon
|
||||
)
|
||||
db.session.add(ndt)
|
||||
logger.debug(f"Created network device type: {name}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Network plugin uninstalled")
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@click.group('network')
|
||||
def networkcli():
|
||||
"""Network plugin commands."""
|
||||
pass
|
||||
|
||||
@networkcli.command('list-types')
|
||||
def list_types():
|
||||
"""List all network device types."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
types = NetworkDeviceType.query.filter_by(isactive=True).all()
|
||||
if not types:
|
||||
click.echo('No network device types found.')
|
||||
return
|
||||
|
||||
click.echo('Network Device Types:')
|
||||
for t in types:
|
||||
click.echo(f" [{t.networkdevicetypeid}] {t.networkdevicetype}")
|
||||
|
||||
@networkcli.command('stats')
|
||||
def stats():
|
||||
"""Show network device statistics."""
|
||||
from flask import current_app
|
||||
from shopdb.api import Asset
|
||||
|
||||
with current_app.app_context():
|
||||
total = db.session.query(NetworkDevice).join(Asset).filter(
|
||||
Asset.isactive == True
|
||||
).count()
|
||||
|
||||
click.echo(f"Total active network devices: {total}")
|
||||
|
||||
# By type
|
||||
by_type = db.session.query(
|
||||
NetworkDeviceType.networkdevicetype,
|
||||
db.func.count(NetworkDevice.networkdeviceid)
|
||||
).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid
|
||||
).join(Asset, Asset.assetid == NetworkDevice.assetid
|
||||
).filter(Asset.isactive == True
|
||||
).group_by(NetworkDeviceType.networkdevicetype
|
||||
).all()
|
||||
|
||||
if by_type:
|
||||
click.echo("\nBy Type:")
|
||||
for t, c in by_type:
|
||||
click.echo(f" {t}: {c}")
|
||||
|
||||
@networkcli.command('find')
|
||||
@click.argument('hostname')
|
||||
def find_by_hostname(hostname):
|
||||
"""Find a network device by hostname."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
netdev = NetworkDevice.query.filter(
|
||||
NetworkDevice.hostname.ilike(f'%{hostname}%')
|
||||
).first()
|
||||
|
||||
if not netdev:
|
||||
click.echo(f'No network device found matching hostname: {hostname}')
|
||||
return
|
||||
|
||||
click.echo(f'Found: {netdev.hostname}')
|
||||
click.echo(f' Asset: {netdev.asset.assetnumber}')
|
||||
click.echo(f' Type: {netdev.networkdevicetype.networkdevicetype if netdev.networkdevicetype else "N/A"}')
|
||||
click.echo(f' Firmware: {netdev.firmwareversion or "N/A"}')
|
||||
click.echo(f' PoE: {"Yes" if netdev.ispoe else "No"}')
|
||||
|
||||
return [networkcli]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Return dashboard widget definitions."""
|
||||
return [
|
||||
{
|
||||
'name': 'Network Status',
|
||||
'component': 'NetworkStatusWidget',
|
||||
'endpoint': '/api/network/dashboard/summary',
|
||||
'size': 'medium',
|
||||
'position': 7,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
{
|
||||
'name': 'Network',
|
||||
'icon': 'network-wired',
|
||||
'route': '/network',
|
||||
'position': 18,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -4,14 +4,7 @@ from datetime import datetime
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, employee_connection
|
||||
|
||||
from ..models import Notification, NotificationType
|
||||
|
||||
@@ -93,7 +86,7 @@ def list_notifications():
|
||||
query = query.filter(Notification.isactive == True)
|
||||
|
||||
# Type filter
|
||||
if type_id := request.args.get('type_id'):
|
||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
||||
query = query.filter(Notification.notificationtypeid == int(type_id))
|
||||
|
||||
# Current filter (active based on dates)
|
||||
@@ -522,11 +515,7 @@ def get_shopfloor_notifications():
|
||||
# Try to get picture from wjf_employees
|
||||
if n.employeesso and n.employeesso.isdigit():
|
||||
try:
|
||||
import pymysql
|
||||
conn = pymysql.connect(
|
||||
host='localhost', user='root', password='rootpassword',
|
||||
database='wjf_employees', cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
conn = employee_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(n.employeesso),))
|
||||
emp = cur.fetchone()
|
||||
@@ -555,11 +544,7 @@ def get_shopfloor_notifications():
|
||||
picture = None
|
||||
if sso.isdigit():
|
||||
try:
|
||||
import pymysql
|
||||
conn = pymysql.connect(
|
||||
host='localhost', user='root', password='rootpassword',
|
||||
database='wjf_employees', cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
conn = employee_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
|
||||
emp = cur.fetchone()
|
||||
@@ -591,11 +576,7 @@ def get_shopfloor_notifications():
|
||||
picture = None
|
||||
if sso.isdigit():
|
||||
try:
|
||||
import pymysql
|
||||
conn = pymysql.connect(
|
||||
host='localhost', user='root', password='rootpassword',
|
||||
database='wjf_employees', cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
conn = employee_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
|
||||
emp = cur.fetchone()
|
||||
|
||||
@@ -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,157 +1,157 @@
|
||||
"""Notifications plugin models - adapted to existing database schema."""
|
||||
|
||||
from datetime import datetime
|
||||
from shopdb.extensions import db
|
||||
|
||||
|
||||
class NotificationType(db.Model):
|
||||
"""
|
||||
Notification type classification.
|
||||
Matches existing notificationtypes table.
|
||||
"""
|
||||
__tablename__ = 'notificationtypes'
|
||||
|
||||
notificationtypeid = db.Column(db.Integer, primary_key=True)
|
||||
typename = db.Column(db.String(50), nullable=False)
|
||||
typedescription = db.Column(db.Text)
|
||||
typecolor = db.Column(db.String(20), default='#17a2b8')
|
||||
isactive = db.Column(db.Boolean, default=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NotificationType {self.typename}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'notificationtypeid': self.notificationtypeid,
|
||||
'typename': self.typename,
|
||||
'typedescription': self.typedescription,
|
||||
'typecolor': self.typecolor,
|
||||
'isactive': self.isactive
|
||||
}
|
||||
|
||||
|
||||
class Notification(db.Model):
|
||||
"""
|
||||
Notification/announcement model.
|
||||
Matches existing notifications table schema.
|
||||
"""
|
||||
__tablename__ = 'notifications'
|
||||
|
||||
notificationid = db.Column(db.Integer, primary_key=True)
|
||||
notificationtypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('notificationtypes.notificationtypeid'),
|
||||
nullable=True
|
||||
)
|
||||
businessunitid = db.Column(db.Integer, nullable=True)
|
||||
appid = db.Column(db.Integer, nullable=True)
|
||||
notification = db.Column(db.Text, nullable=False, comment='The message content')
|
||||
starttime = db.Column(db.DateTime, nullable=True)
|
||||
endtime = db.Column(db.DateTime, nullable=True)
|
||||
ticketnumber = db.Column(db.String(50), nullable=True)
|
||||
link = db.Column(db.String(500), nullable=True)
|
||||
isactive = db.Column(db.Boolean, default=True)
|
||||
isshopfloor = db.Column(db.Boolean, default=False)
|
||||
employeesso = db.Column(db.String(100), nullable=True)
|
||||
employeename = db.Column(db.String(100), nullable=True)
|
||||
|
||||
# Relationships
|
||||
notificationtype = db.relationship('NotificationType', backref='notifications')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Notification {self.notificationid}>"
|
||||
|
||||
@property
|
||||
def is_current(self):
|
||||
"""Check if notification is currently active based on dates."""
|
||||
now = datetime.utcnow()
|
||||
if not self.isactive:
|
||||
return False
|
||||
if self.starttime and now < self.starttime:
|
||||
return False
|
||||
if self.endtime and now > self.endtime:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
"""Get title - first line or first 100 chars of notification."""
|
||||
if not self.notification:
|
||||
return ''
|
||||
lines = self.notification.split('\n')
|
||||
return lines[0][:100] if lines else self.notification[:100]
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related data."""
|
||||
result = {
|
||||
'notificationid': self.notificationid,
|
||||
'notificationtypeid': self.notificationtypeid,
|
||||
'businessunitid': self.businessunitid,
|
||||
'appid': self.appid,
|
||||
'notification': self.notification,
|
||||
'title': self.title,
|
||||
'message': self.notification,
|
||||
'starttime': self.starttime.isoformat() if self.starttime else None,
|
||||
'endtime': self.endtime.isoformat() if self.endtime else None,
|
||||
'startdate': self.starttime.isoformat() if self.starttime else None,
|
||||
'enddate': self.endtime.isoformat() if self.endtime else None,
|
||||
'ticketnumber': self.ticketnumber,
|
||||
'link': self.link,
|
||||
'linkurl': self.link,
|
||||
'isactive': bool(self.isactive) if self.isactive is not None else True,
|
||||
'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False,
|
||||
'employeesso': self.employeesso,
|
||||
'employeename': self.employeename,
|
||||
'iscurrent': self.is_current
|
||||
}
|
||||
|
||||
# Add type info
|
||||
if self.notificationtype:
|
||||
result['typename'] = self.notificationtype.typename
|
||||
result['typecolor'] = self.notificationtype.typecolor
|
||||
|
||||
return result
|
||||
|
||||
def to_calendar_event(self):
|
||||
"""Convert to FullCalendar event format."""
|
||||
# Map Bootstrap color names to hex colors
|
||||
color_map = {
|
||||
'success': '#04b962',
|
||||
'warning': '#ff8800',
|
||||
'danger': '#f5365c',
|
||||
'info': '#14abef',
|
||||
'primary': '#7934f3',
|
||||
'secondary': '#94614f',
|
||||
'recognition': '#14abef', # Blue for recognition
|
||||
}
|
||||
|
||||
raw_color = self.notificationtype.typecolor if self.notificationtype else 'info'
|
||||
# Use mapped color if it's a Bootstrap name, otherwise use as-is (hex)
|
||||
color = color_map.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef')
|
||||
|
||||
# For recognition notifications, include employee name (or SSO as fallback) in title
|
||||
title = self.title
|
||||
if raw_color == 'recognition':
|
||||
employee_display = self.employeename or self.employeesso
|
||||
if employee_display:
|
||||
title = f"{employee_display}: {title}"
|
||||
|
||||
return {
|
||||
'id': self.notificationid,
|
||||
'title': title,
|
||||
'start': self.starttime.isoformat() if self.starttime else None,
|
||||
'end': self.endtime.isoformat() if self.endtime else None,
|
||||
'allDay': True,
|
||||
'backgroundColor': color,
|
||||
'borderColor': color,
|
||||
'extendedProps': {
|
||||
'notificationid': self.notificationid,
|
||||
'message': self.notification,
|
||||
'typename': self.notificationtype.typename if self.notificationtype else None,
|
||||
'typecolor': raw_color,
|
||||
'linkurl': self.link,
|
||||
'ticketnumber': self.ticketnumber,
|
||||
'employeename': self.employeename,
|
||||
'employeesso': self.employeesso,
|
||||
}
|
||||
}
|
||||
"""Notifications plugin models - adapted to existing database schema."""
|
||||
|
||||
from datetime import datetime
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
class NotificationType(db.Model):
|
||||
"""
|
||||
Notification type classification.
|
||||
Matches existing notificationtypes table.
|
||||
"""
|
||||
__tablename__ = 'notificationtypes'
|
||||
|
||||
notificationtypeid = db.Column(db.Integer, primary_key=True)
|
||||
typename = db.Column(db.String(50), nullable=False)
|
||||
typedescription = db.Column(db.Text)
|
||||
typecolor = db.Column(db.String(20), default='#17a2b8')
|
||||
isactive = db.Column(db.Boolean, default=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NotificationType {self.typename}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'notificationtypeid': self.notificationtypeid,
|
||||
'typename': self.typename,
|
||||
'typedescription': self.typedescription,
|
||||
'typecolor': self.typecolor,
|
||||
'isactive': self.isactive
|
||||
}
|
||||
|
||||
|
||||
class Notification(db.Model):
|
||||
"""
|
||||
Notification/announcement model.
|
||||
Matches existing notifications table schema.
|
||||
"""
|
||||
__tablename__ = 'notifications'
|
||||
|
||||
notificationid = db.Column(db.Integer, primary_key=True)
|
||||
notificationtypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('notificationtypes.notificationtypeid'),
|
||||
nullable=True
|
||||
)
|
||||
businessunitid = db.Column(db.Integer, nullable=True)
|
||||
appid = db.Column(db.Integer, nullable=True)
|
||||
notification = db.Column(db.Text, nullable=False, comment='The message content')
|
||||
starttime = db.Column(db.DateTime, nullable=True)
|
||||
endtime = db.Column(db.DateTime, nullable=True)
|
||||
ticketnumber = db.Column(db.String(50), nullable=True)
|
||||
link = db.Column(db.String(500), nullable=True)
|
||||
isactive = db.Column(db.Boolean, default=True)
|
||||
isshopfloor = db.Column(db.Boolean, default=False)
|
||||
employeesso = db.Column(db.String(100), nullable=True)
|
||||
employeename = db.Column(db.String(100), nullable=True)
|
||||
|
||||
# Relationships
|
||||
notificationtype = db.relationship('NotificationType', backref='notifications')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Notification {self.notificationid}>"
|
||||
|
||||
@property
|
||||
def is_current(self):
|
||||
"""Check if notification is currently active based on dates."""
|
||||
now = datetime.utcnow()
|
||||
if not self.isactive:
|
||||
return False
|
||||
if self.starttime and now < self.starttime:
|
||||
return False
|
||||
if self.endtime and now > self.endtime:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
"""Get title - first line or first 100 chars of notification."""
|
||||
if not self.notification:
|
||||
return ''
|
||||
lines = self.notification.split('\n')
|
||||
return lines[0][:100] if lines else self.notification[:100]
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related data."""
|
||||
result = {
|
||||
'notificationid': self.notificationid,
|
||||
'notificationtypeid': self.notificationtypeid,
|
||||
'businessunitid': self.businessunitid,
|
||||
'appid': self.appid,
|
||||
'notification': self.notification,
|
||||
'title': self.title,
|
||||
'message': self.notification,
|
||||
'starttime': self.starttime.isoformat() if self.starttime else None,
|
||||
'endtime': self.endtime.isoformat() if self.endtime else None,
|
||||
'startdate': self.starttime.isoformat() if self.starttime else None,
|
||||
'enddate': self.endtime.isoformat() if self.endtime else None,
|
||||
'ticketnumber': self.ticketnumber,
|
||||
'link': self.link,
|
||||
'linkurl': self.link,
|
||||
'isactive': bool(self.isactive) if self.isactive is not None else True,
|
||||
'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False,
|
||||
'employeesso': self.employeesso,
|
||||
'employeename': self.employeename,
|
||||
'iscurrent': self.is_current
|
||||
}
|
||||
|
||||
# Add type info
|
||||
if self.notificationtype:
|
||||
result['typename'] = self.notificationtype.typename
|
||||
result['typecolor'] = self.notificationtype.typecolor
|
||||
|
||||
return result
|
||||
|
||||
def to_calendar_event(self):
|
||||
"""Convert to FullCalendar event format."""
|
||||
# Map Bootstrap color names to hex colors
|
||||
color_map = {
|
||||
'success': '#04b962',
|
||||
'warning': '#ff8800',
|
||||
'danger': '#f5365c',
|
||||
'info': '#14abef',
|
||||
'primary': '#7934f3',
|
||||
'secondary': '#94614f',
|
||||
'recognition': '#14abef', # Blue for recognition
|
||||
}
|
||||
|
||||
raw_color = self.notificationtype.typecolor if self.notificationtype else 'info'
|
||||
# Use mapped color if it's a Bootstrap name, otherwise use as-is (hex)
|
||||
color = color_map.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef')
|
||||
|
||||
# For recognition notifications, include employee name (or SSO as fallback) in title
|
||||
title = self.title
|
||||
if raw_color == 'recognition':
|
||||
employee_display = self.employeename or self.employeesso
|
||||
if employee_display:
|
||||
title = f"{employee_display}: {title}"
|
||||
|
||||
return {
|
||||
'id': self.notificationid,
|
||||
'title': title,
|
||||
'start': self.starttime.isoformat() if self.starttime else None,
|
||||
'end': self.endtime.isoformat() if self.endtime else None,
|
||||
'allDay': True,
|
||||
'backgroundColor': color,
|
||||
'borderColor': color,
|
||||
'extendedProps': {
|
||||
'notificationid': self.notificationid,
|
||||
'message': self.notification,
|
||||
'typename': self.notificationtype.typename if self.notificationtype else None,
|
||||
'typecolor': raw_color,
|
||||
'linkurl': self.link,
|
||||
'ticketnumber': self.ticketnumber,
|
||||
'employeename': self.employeename,
|
||||
'employeesso': self.employeesso,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,204 +1,204 @@
|
||||
"""Notifications plugin main class."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.extensions import db
|
||||
|
||||
from .models import Notification, NotificationType
|
||||
from .api import notifications_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationsPlugin(BasePlugin):
|
||||
"""
|
||||
Notifications plugin - manages announcements and notifications.
|
||||
|
||||
Provides functionality for:
|
||||
- Creating and managing notifications/announcements
|
||||
- Displaying banner notifications
|
||||
- Calendar view of notifications
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'notifications'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Notifications and announcements management'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/notifications'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
return notifications_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [Notification, NotificationType]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Notifications plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
self._ensure_notification_types()
|
||||
logger.info("Notifications plugin installed")
|
||||
|
||||
def _ensure_notification_types(self) -> None:
|
||||
"""Ensure default notification types exist."""
|
||||
default_types = [
|
||||
('Awareness', 'General awareness notification', '#17a2b8', 'info-circle'),
|
||||
('Change', 'Planned change notification', '#ffc107', 'exchange-alt'),
|
||||
('Incident', 'Incident or outage notification', '#dc3545', 'exclamation-triangle'),
|
||||
('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'wrench'),
|
||||
('General', 'General announcement', '#28a745', 'bullhorn'),
|
||||
]
|
||||
|
||||
for typename, description, color, icon in default_types:
|
||||
existing = NotificationType.query.filter_by(typename=typename).first()
|
||||
if not existing:
|
||||
t = NotificationType(
|
||||
typename=typename,
|
||||
description=description,
|
||||
color=color,
|
||||
icon=icon
|
||||
)
|
||||
db.session.add(t)
|
||||
logger.debug(f"Created notification type: {typename}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Notifications plugin uninstalled")
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@click.group('notifications')
|
||||
def notifications_cli():
|
||||
"""Notifications plugin commands."""
|
||||
pass
|
||||
|
||||
@notifications_cli.command('list-types')
|
||||
def list_types():
|
||||
"""List all notification types."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
types = NotificationType.query.filter_by(isactive=True).all()
|
||||
if not types:
|
||||
click.echo('No notification types found.')
|
||||
return
|
||||
|
||||
click.echo('Notification Types:')
|
||||
for t in types:
|
||||
click.echo(f" [{t.notificationtypeid}] {t.typename} ({t.color})")
|
||||
|
||||
@notifications_cli.command('stats')
|
||||
def stats():
|
||||
"""Show notification statistics."""
|
||||
from flask import current_app
|
||||
from datetime import datetime
|
||||
|
||||
with current_app.app_context():
|
||||
now = datetime.utcnow()
|
||||
|
||||
total = Notification.query.filter(
|
||||
Notification.isactive == True
|
||||
).count()
|
||||
|
||||
active = Notification.query.filter(
|
||||
Notification.isactive == True,
|
||||
Notification.startdate <= now,
|
||||
db.or_(
|
||||
Notification.enddate.is_(None),
|
||||
Notification.enddate >= now
|
||||
)
|
||||
).count()
|
||||
|
||||
click.echo(f"Total notifications: {total}")
|
||||
click.echo(f"Currently active: {active}")
|
||||
|
||||
@notifications_cli.command('create')
|
||||
@click.option('--title', required=True, help='Notification title')
|
||||
@click.option('--message', required=True, help='Notification message')
|
||||
@click.option('--type', 'type_name', default='General', help='Notification type')
|
||||
def create_notification(title, message, type_name):
|
||||
"""Create a new notification."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
ntype = NotificationType.query.filter_by(typename=type_name).first()
|
||||
if not ntype:
|
||||
click.echo(f"Error: Notification type '{type_name}' not found.")
|
||||
return
|
||||
|
||||
n = Notification(
|
||||
title=title,
|
||||
message=message,
|
||||
notificationtypeid=ntype.notificationtypeid
|
||||
)
|
||||
db.session.add(n)
|
||||
db.session.commit()
|
||||
|
||||
click.echo(f"Created notification #{n.notificationid}: {title}")
|
||||
|
||||
return [notifications_cli]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Return dashboard widget definitions."""
|
||||
return [
|
||||
{
|
||||
'name': 'Active Notifications',
|
||||
'component': 'NotificationsWidget',
|
||||
'endpoint': '/api/notifications/dashboard/summary',
|
||||
'size': 'small',
|
||||
'position': 1,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
{
|
||||
'name': 'Notifications',
|
||||
'icon': 'bell',
|
||||
'route': '/notifications',
|
||||
'position': 5,
|
||||
},
|
||||
{
|
||||
'name': 'Calendar',
|
||||
'icon': 'calendar',
|
||||
'route': '/calendar',
|
||||
'position': 6,
|
||||
},
|
||||
]
|
||||
"""Notifications plugin main class."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db
|
||||
|
||||
from .models import Notification, NotificationType
|
||||
from .api import notifications_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationsPlugin(BasePlugin):
|
||||
"""
|
||||
Notifications plugin - manages announcements and notifications.
|
||||
|
||||
Provides functionality for:
|
||||
- Creating and managing notifications/announcements
|
||||
- Displaying banner notifications
|
||||
- Calendar view of notifications
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'notifications'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Notifications and announcements management'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/notifications'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
return notifications_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [Notification, NotificationType]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Notifications plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
self._ensure_notification_types()
|
||||
logger.info("Notifications plugin installed")
|
||||
|
||||
def _ensure_notification_types(self) -> None:
|
||||
"""Ensure default notification types exist."""
|
||||
default_types = [
|
||||
('Awareness', 'General awareness notification', '#17a2b8', 'info-circle'),
|
||||
('Change', 'Planned change notification', '#ffc107', 'exchange-alt'),
|
||||
('Incident', 'Incident or outage notification', '#dc3545', 'exclamation-triangle'),
|
||||
('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'wrench'),
|
||||
('General', 'General announcement', '#28a745', 'bullhorn'),
|
||||
]
|
||||
|
||||
for typename, description, color, icon in default_types:
|
||||
existing = NotificationType.query.filter_by(typename=typename).first()
|
||||
if not existing:
|
||||
t = NotificationType(
|
||||
typename=typename,
|
||||
description=description,
|
||||
color=color,
|
||||
icon=icon
|
||||
)
|
||||
db.session.add(t)
|
||||
logger.debug(f"Created notification type: {typename}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Notifications plugin uninstalled")
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@click.group('notifications')
|
||||
def notifications_cli():
|
||||
"""Notifications plugin commands."""
|
||||
pass
|
||||
|
||||
@notifications_cli.command('list-types')
|
||||
def list_types():
|
||||
"""List all notification types."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
types = NotificationType.query.filter_by(isactive=True).all()
|
||||
if not types:
|
||||
click.echo('No notification types found.')
|
||||
return
|
||||
|
||||
click.echo('Notification Types:')
|
||||
for t in types:
|
||||
click.echo(f" [{t.notificationtypeid}] {t.typename} ({t.color})")
|
||||
|
||||
@notifications_cli.command('stats')
|
||||
def stats():
|
||||
"""Show notification statistics."""
|
||||
from flask import current_app
|
||||
from datetime import datetime
|
||||
|
||||
with current_app.app_context():
|
||||
now = datetime.utcnow()
|
||||
|
||||
total = Notification.query.filter(
|
||||
Notification.isactive == True
|
||||
).count()
|
||||
|
||||
active = Notification.query.filter(
|
||||
Notification.isactive == True,
|
||||
Notification.startdate <= now,
|
||||
db.or_(
|
||||
Notification.enddate.is_(None),
|
||||
Notification.enddate >= now
|
||||
)
|
||||
).count()
|
||||
|
||||
click.echo(f"Total notifications: {total}")
|
||||
click.echo(f"Currently active: {active}")
|
||||
|
||||
@notifications_cli.command('create')
|
||||
@click.option('--title', required=True, help='Notification title')
|
||||
@click.option('--message', required=True, help='Notification message')
|
||||
@click.option('--type', 'type_name', default='General', help='Notification type')
|
||||
def create_notification(title, message, type_name):
|
||||
"""Create a new notification."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
ntype = NotificationType.query.filter_by(typename=type_name).first()
|
||||
if not ntype:
|
||||
click.echo(f"Error: Notification type '{type_name}' not found.")
|
||||
return
|
||||
|
||||
n = Notification(
|
||||
title=title,
|
||||
message=message,
|
||||
notificationtypeid=ntype.notificationtypeid
|
||||
)
|
||||
db.session.add(n)
|
||||
db.session.commit()
|
||||
|
||||
click.echo(f"Created notification #{n.notificationid}: {title}")
|
||||
|
||||
return [notifications_cli]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Return dashboard widget definitions."""
|
||||
return [
|
||||
{
|
||||
'name': 'Active Notifications',
|
||||
'component': 'NotificationsWidget',
|
||||
'endpoint': '/api/notifications/dashboard/summary',
|
||||
'size': 'small',
|
||||
'position': 1,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
{
|
||||
'name': 'Notifications',
|
||||
'icon': 'bell',
|
||||
'route': '/notifications',
|
||||
'position': 5,
|
||||
},
|
||||
{
|
||||
'name': 'Calendar',
|
||||
'icon': 'calendar',
|
||||
'route': '/calendar',
|
||||
'position': 6,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Printers plugin API."""
|
||||
|
||||
from .routes import printers_bp # Legacy Machine-based API
|
||||
from .asset_routes import printers_asset_bp # New Asset-based API
|
||||
from .asset_routes import printers_asset_bp # Asset-based API
|
||||
|
||||
__all__ = [
|
||||
'printers_bp', # Legacy
|
||||
'printers_asset_bp', # New
|
||||
'printers_asset_bp',
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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."""
|
||||
|
||||
from .printer_extension import PrinterData # Legacy model for Machine-based architecture
|
||||
from .printer import Printer, PrinterType # New Asset-based models
|
||||
from .printer import Printer, PrinterType # Asset-based models
|
||||
from .model_supply import ( # data-driven model -> toner/drum/waste mapping
|
||||
ModelSupply,
|
||||
SUPPLY_TYPES,
|
||||
SUPPLY_COLORS,
|
||||
CAPACITY_TIERS,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'PrinterData', # Legacy
|
||||
'Printer', # New
|
||||
'PrinterType', # New
|
||||
'Printer',
|
||||
'PrinterType',
|
||||
'ModelSupply',
|
||||
'SUPPLY_TYPES',
|
||||
'SUPPLY_COLORS',
|
||||
'CAPACITY_TIERS',
|
||||
]
|
||||
|
||||
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,122 +1,121 @@
|
||||
"""Printer plugin models - new Asset-based architecture."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models.base import BaseModel
|
||||
|
||||
|
||||
class PrinterType(BaseModel):
|
||||
"""
|
||||
Printer type classification.
|
||||
|
||||
Examples: Laser, Inkjet, Label, MFP, Plotter, etc.
|
||||
"""
|
||||
__tablename__ = 'printertypes'
|
||||
|
||||
printertypeid = db.Column(db.Integer, primary_key=True)
|
||||
printertype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PrinterType {self.printertype}>"
|
||||
|
||||
|
||||
class Printer(BaseModel):
|
||||
"""
|
||||
Printer-specific extension data (new Asset architecture).
|
||||
|
||||
Links to core Asset table via assetid.
|
||||
Stores printer-specific fields like type, Windows name, share name, etc.
|
||||
"""
|
||||
__tablename__ = 'printers'
|
||||
|
||||
printerid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Link to core asset
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Printer classification
|
||||
printertypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('printertypes.printertypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Vendor
|
||||
vendorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vendors.vendorid'),
|
||||
nullable=True
|
||||
)
|
||||
modelnumberid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('models.modelnumberid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Network identity
|
||||
hostname = db.Column(
|
||||
db.String(100),
|
||||
index=True,
|
||||
comment='Network hostname'
|
||||
)
|
||||
|
||||
# Windows/Network naming
|
||||
windowsname = db.Column(
|
||||
db.String(255),
|
||||
comment='Windows printer name (e.g., \\\\server\\printer)'
|
||||
)
|
||||
sharename = db.Column(
|
||||
db.String(100),
|
||||
comment='CSF/share name'
|
||||
)
|
||||
|
||||
# Installation
|
||||
iscsf = db.Column(db.Boolean, default=False, comment='Is CSF printer')
|
||||
installpath = db.Column(db.String(255), comment='Driver install path')
|
||||
|
||||
# Printer PIN (for secure print)
|
||||
pin = db.Column(db.String(20))
|
||||
|
||||
# Features
|
||||
iscolor = db.Column(db.Boolean, default=False, comment='Color capable')
|
||||
isduplex = db.Column(db.Boolean, default=False, comment='Duplex capable')
|
||||
isnetwork = db.Column(db.Boolean, default=True, comment='Network connected')
|
||||
|
||||
# Relationships
|
||||
asset = db.relationship(
|
||||
'Asset',
|
||||
backref=db.backref('printer', uselist=False, lazy='joined')
|
||||
)
|
||||
printertype = db.relationship('PrinterType', backref='printers')
|
||||
vendor = db.relationship('Vendor', backref='printer_items')
|
||||
model = db.relationship('Model', backref='printer_items')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_printer_type', 'printertypeid'),
|
||||
db.Index('idx_printer_hostname', 'hostname'),
|
||||
db.Index('idx_printer_windowsname', 'windowsname'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Printer {self.hostname or self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related names."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names
|
||||
if self.printertype:
|
||||
result['printertypename'] = self.printertype.printertype
|
||||
if self.vendor:
|
||||
result['vendorname'] = self.vendor.vendor
|
||||
if self.model:
|
||||
result['modelname'] = self.model.modelnumber
|
||||
|
||||
return result
|
||||
"""Printer plugin models - new Asset-based architecture."""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class PrinterType(BaseModel):
|
||||
"""
|
||||
Printer type classification.
|
||||
|
||||
Examples: Laser, Inkjet, Label, MFP, Plotter, etc.
|
||||
"""
|
||||
__tablename__ = 'printertypes'
|
||||
|
||||
printertypeid = db.Column(db.Integer, primary_key=True)
|
||||
printertype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PrinterType {self.printertype}>"
|
||||
|
||||
|
||||
class Printer(BaseModel):
|
||||
"""
|
||||
Printer-specific extension data (new Asset architecture).
|
||||
|
||||
Links to core Asset table via assetid.
|
||||
Stores printer-specific fields like type, Windows name, share name, etc.
|
||||
"""
|
||||
__tablename__ = 'printers'
|
||||
|
||||
printerid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Link to core asset
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Printer classification
|
||||
printertypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('printertypes.printertypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Vendor
|
||||
vendorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vendors.vendorid'),
|
||||
nullable=True
|
||||
)
|
||||
modelnumberid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('models.modelnumberid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Network identity
|
||||
hostname = db.Column(
|
||||
db.String(100),
|
||||
index=True,
|
||||
comment='Network hostname'
|
||||
)
|
||||
|
||||
# Windows/Network naming
|
||||
windowsname = db.Column(
|
||||
db.String(255),
|
||||
comment='Windows printer name (e.g., \\\\server\\printer)'
|
||||
)
|
||||
sharename = db.Column(
|
||||
db.String(100),
|
||||
comment='CSF/share name'
|
||||
)
|
||||
|
||||
# Installation
|
||||
iscsf = db.Column(db.Boolean, default=False, comment='Is CSF printer')
|
||||
installpath = db.Column(db.String(255), comment='Driver install path')
|
||||
|
||||
# Printer PIN (for secure print)
|
||||
pin = db.Column(db.String(20))
|
||||
|
||||
# Features
|
||||
iscolor = db.Column(db.Boolean, default=False, comment='Color capable')
|
||||
isduplex = db.Column(db.Boolean, default=False, comment='Duplex capable')
|
||||
isnetwork = db.Column(db.Boolean, default=True, comment='Network connected')
|
||||
|
||||
# Relationships
|
||||
asset = db.relationship(
|
||||
'Asset',
|
||||
backref=db.backref('printer', uselist=False, lazy='joined')
|
||||
)
|
||||
printertype = db.relationship('PrinterType', backref='printers')
|
||||
vendor = db.relationship('Vendor', backref='printer_items')
|
||||
model = db.relationship('Model', backref='printer_items')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_printer_type', 'printertypeid'),
|
||||
db.Index('idx_printer_hostname', 'hostname'),
|
||||
db.Index('idx_printer_windowsname', 'windowsname'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Printer {self.hostname or self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related names."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names
|
||||
if self.printertype:
|
||||
result['printertypename'] = self.printertype.printertype
|
||||
if self.vendor:
|
||||
result['vendorname'] = self.vendor.vendor
|
||||
if self.model:
|
||||
result['modelname'] = self.model.modelnumber
|
||||
|
||||
return result
|
||||
|
||||
@@ -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
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models.machine import MachineType
|
||||
from shopdb.core.models import AssetType
|
||||
from shopdb.api import db, AssetType
|
||||
|
||||
from .models import PrinterData, Printer, PrinterType
|
||||
from .api import printers_bp, printers_asset_bp
|
||||
from .models import Printer, PrinterType, ModelSupply
|
||||
from .api import printers_asset_bp
|
||||
from .services import ZabbixService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -74,9 +72,9 @@ class PrintersPlugin(BasePlugin):
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [
|
||||
PrinterData, # Legacy Machine-based
|
||||
Printer, # New Asset-based
|
||||
PrinterType, # New printer type classification
|
||||
Printer, # Asset-based
|
||||
PrinterType, # printer type classification
|
||||
ModelSupply, # model -> toner/drum/waste part numbers
|
||||
]
|
||||
|
||||
def get_services(self) -> Dict[str, Type]:
|
||||
@@ -97,9 +95,6 @@ class PrintersPlugin(BasePlugin):
|
||||
app.config.setdefault('ZABBIX_URL', '')
|
||||
app.config.setdefault('ZABBIX_TOKEN', '')
|
||||
|
||||
# Register legacy blueprint for backward compatibility
|
||||
app.register_blueprint(printers_bp, url_prefix='/api/printers/legacy')
|
||||
|
||||
logger.info(f"Printers plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
@@ -107,7 +102,6 @@ class PrintersPlugin(BasePlugin):
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
self._ensure_printer_types()
|
||||
self._ensure_legacy_machine_types()
|
||||
logger.info("Printers plugin installed")
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
@@ -131,6 +125,7 @@ class PrintersPlugin(BasePlugin):
|
||||
('Laser', 'Standard laser printer', 'printer'),
|
||||
('Inkjet', 'Inkjet printer', 'printer'),
|
||||
('Label', 'Label/barcode printer', 'barcode'),
|
||||
('Card', 'ID / card printer', 'id-card'),
|
||||
('MFP', 'Multifunction printer with scan/copy/fax', 'printer'),
|
||||
('Plotter', 'Large format plotter', 'drafting-compass'),
|
||||
('Thermal', 'Thermal printer', 'temperature-high'),
|
||||
@@ -151,30 +146,6 @@ class PrintersPlugin(BasePlugin):
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_legacy_machine_types(self) -> None:
|
||||
"""Ensure basic printer machine types exist (legacy architecture)."""
|
||||
printertypes = [
|
||||
('Laser Printer', 'Printer', 'Standard laser printer'),
|
||||
('Inkjet Printer', 'Printer', 'Inkjet printer'),
|
||||
('Label Printer', 'Printer', 'Label/barcode printer'),
|
||||
('Multifunction Printer', 'Printer', 'MFP with scan/copy/fax'),
|
||||
('Plotter', 'Printer', 'Large format plotter'),
|
||||
]
|
||||
|
||||
for name, category, description in printertypes:
|
||||
existing = MachineType.query.filter_by(machinetype=name).first()
|
||||
if not existing:
|
||||
mt = MachineType(
|
||||
machinetype=name,
|
||||
category=category,
|
||||
description=description,
|
||||
icon='printer'
|
||||
)
|
||||
db.session.add(mt)
|
||||
logger.debug(f"Created machine type: {name}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Printers plugin uninstalled")
|
||||
@@ -209,6 +180,19 @@ class PrintersPlugin(BasePlugin):
|
||||
for supply in supplies:
|
||||
click.echo(f" {supply['name']}: {supply['level']}%")
|
||||
|
||||
@printerscli.command('seed-supplies')
|
||||
def seedsuppliescommand():
|
||||
"""Seed corrected model->toner part numbers into modelsupplies."""
|
||||
from flask import current_app
|
||||
from .services import seedsupplies
|
||||
|
||||
with current_app.app_context():
|
||||
summary = seedsupplies()
|
||||
click.echo(
|
||||
f"Seeded supplies: {summary['suppliesadded']} added across "
|
||||
f"{summary['modelstouched']} models."
|
||||
)
|
||||
|
||||
return [printerscli]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
"""Printers plugin services."""
|
||||
|
||||
from .zabbix_service import ZabbixService
|
||||
from .supply_parts import (
|
||||
classifysupply,
|
||||
derivesupplytype,
|
||||
derivecolor,
|
||||
lookupsupplies,
|
||||
)
|
||||
from .seed_supplies import seedsupplies
|
||||
|
||||
__all__ = ['ZabbixService']
|
||||
__all__ = [
|
||||
'ZabbixService',
|
||||
'classifysupply',
|
||||
'derivesupplytype',
|
||||
'derivecolor',
|
||||
'lookupsupplies',
|
||||
'seedsupplies',
|
||||
]
|
||||
|
||||
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
|
||||
from typing import Dict, List, Optional
|
||||
@@ -6,59 +24,65 @@ from typing import Dict, List, Optional
|
||||
import requests
|
||||
from flask import current_app
|
||||
|
||||
from shopdb.extensions import cache
|
||||
from shopdb.api import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZabbixService:
|
||||
"""
|
||||
Zabbix API service for real-time printer supply lookups.
|
||||
"""Zabbix API client for printer supply and ping lookups."""
|
||||
|
||||
Queries Zabbix by IP address to get current supply levels.
|
||||
Use getsuppliesbyip_cached() for cached lookups or
|
||||
getsuppliesbyip() for live data.
|
||||
CACHE_TTL = 300 # 5 min, matches the classic Application cache
|
||||
REACHABLE_CHECK_TTL = 60
|
||||
|
||||
Configuration:
|
||||
ZABBIX_ENABLED: Set to True to enable Zabbix integration (default: False)
|
||||
ZABBIX_URL: Zabbix API URL (e.g., http://zabbix.example.com:8080)
|
||||
ZABBIX_TOKEN: Zabbix API authentication token
|
||||
"""
|
||||
# quick fail for the reachability probe
|
||||
REACHABLE_TIMEOUT = 1.0
|
||||
# (connect, read) for real API calls; item.get is slow, give it room
|
||||
API_TIMEOUT = (3.0, 5.0)
|
||||
|
||||
CACHE_TTL = 600 # 10 minutes
|
||||
REACHABLE_CHECK_TTL = 60 # Check reachability every 60 seconds
|
||||
# supply-level item tags, mirrors zabbix.asp GetPrinterTonerLevels
|
||||
SUPPLY_TAGS = [
|
||||
{"tag": "component", "value": "supplies", "operator": 0},
|
||||
{"tag": "type", "value": "level", "operator": 0},
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
self._url = None
|
||||
self._token = None
|
||||
self._enabled = None
|
||||
|
||||
# -- configuration -------------------------------------------------------
|
||||
|
||||
@property
|
||||
def isenabled(self) -> bool:
|
||||
"""Check if Zabbix integration is enabled."""
|
||||
# Check database setting first, fall back to env var
|
||||
from shopdb.core.models import Setting
|
||||
"""Whether the integration is switched on."""
|
||||
from shopdb.api import Setting
|
||||
db_enabled = Setting.get('zabbix_enabled')
|
||||
if db_enabled is not None:
|
||||
return bool(db_enabled)
|
||||
# Fall back to env var for backwards compatibility
|
||||
return current_app.config.get('ZABBIX_ENABLED', False)
|
||||
|
||||
@property
|
||||
def isconfigured(self) -> bool:
|
||||
"""Check if Zabbix is enabled and configured."""
|
||||
"""Enabled, and a URL plus token are present."""
|
||||
if not self.isenabled:
|
||||
return False
|
||||
# Check database settings first, fall back to env vars
|
||||
from shopdb.core.models import Setting
|
||||
from shopdb.api import Setting
|
||||
self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL')
|
||||
self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN')
|
||||
return bool(self._url and self._token)
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
"""Full JSON-RPC endpoint. Accept a base URL or the full path."""
|
||||
url = (self._url or "").rstrip("/")
|
||||
if url.endswith("api_jsonrpc.php"):
|
||||
return url
|
||||
return f"{url}/api_jsonrpc.php"
|
||||
|
||||
@property
|
||||
def isreachable(self) -> bool:
|
||||
"""Check if Zabbix is reachable (cached for 60 seconds)."""
|
||||
if not self.isenabled or not self.isconfigured:
|
||||
"""Cheap connectivity probe, cached for 60s."""
|
||||
if not self.isconfigured:
|
||||
return False
|
||||
|
||||
cache_key = 'zabbix_reachable'
|
||||
@@ -66,138 +90,165 @@ class ZabbixService:
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Quick connectivity check with 500ms timeout
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self._url}/api_jsonrpc.php",
|
||||
timeout=0.5
|
||||
)
|
||||
reachable = response.status_code in (200, 401, 403, 405)
|
||||
response = requests.get(self.endpoint, timeout=self.REACHABLE_TIMEOUT)
|
||||
# any non-5xx answer means the web tier responded, so the server is
|
||||
# up. Zabbix 7.0 returns 412 to a bare GET on api_jsonrpc.php (it
|
||||
# wants a POST with json-rpc content type); that still counts.
|
||||
reachable = response.status_code < 500
|
||||
except requests.RequestException:
|
||||
reachable = False
|
||||
|
||||
cache.set(cache_key, reachable, timeout=self.REACHABLE_CHECK_TTL)
|
||||
logger.debug(f"Zabbix reachability check: {reachable}")
|
||||
logger.debug("Zabbix reachability: %s", reachable)
|
||||
return reachable
|
||||
|
||||
def _apicall(self, method: str, params: Dict) -> Optional[Dict]:
|
||||
"""Make a Zabbix API call."""
|
||||
# -- low level call ------------------------------------------------------
|
||||
|
||||
def _apicall(self, method: str, params: Dict) -> Optional[object]:
|
||||
"""One JSON-RPC call. Returns the result, or None on any error."""
|
||||
if not self.isconfigured:
|
||||
return None
|
||||
|
||||
payload = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': method,
|
||||
'params': params,
|
||||
'auth': self._token,
|
||||
'id': 1
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
"id": 1,
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json-rpc",
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self._url}/api_jsonrpc.php",
|
||||
self.endpoint,
|
||||
json=payload,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
timeout=0.5 # 500ms timeout - fail fast if Zabbix is slow/unreachable
|
||||
headers=headers,
|
||||
timeout=self.API_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if 'error' in data:
|
||||
logger.error(f"Zabbix API error: {data['error']}")
|
||||
return None
|
||||
|
||||
return data.get('result')
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Zabbix API request failed: {e}")
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
logger.error("Zabbix %s call failed: %s", method, exc)
|
||||
return None
|
||||
|
||||
def gethostbyip(self, ip: str) -> Optional[Dict]:
|
||||
"""Find a Zabbix host by IP address."""
|
||||
result = self._apicall('host.get', {
|
||||
'output': ['hostid', 'host', 'name'],
|
||||
'filter': {'ip': ip},
|
||||
'selectInterfaces': ['ip']
|
||||
})
|
||||
if "error" in data:
|
||||
logger.error("Zabbix %s error: %s", method, data["error"])
|
||||
return None
|
||||
|
||||
return data.get("result")
|
||||
|
||||
# -- host / item lookups -------------------------------------------------
|
||||
|
||||
def gethostidbyip(self, ip: str) -> Optional[str]:
|
||||
"""Host id for a printer. Hosts are named by IP in this Zabbix."""
|
||||
result = self._apicall("host.get", {
|
||||
"output": ["hostid"],
|
||||
"filter": {"host": [ip]},
|
||||
})
|
||||
if result:
|
||||
return result[0] if result else None
|
||||
return result[0].get("hostid")
|
||||
return None
|
||||
|
||||
def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]:
|
||||
"""
|
||||
Get printer supply levels by IP address.
|
||||
def _extract_color(self, item: Dict) -> str:
|
||||
"""Pull and normalise the color tag, falling back to the item name."""
|
||||
color = ""
|
||||
for tag in item.get("tags", []) or []:
|
||||
if tag.get("tag") == "color":
|
||||
color = (tag.get("value") or "").lower()
|
||||
break
|
||||
if "black" in color:
|
||||
color = "black"
|
||||
elif color in ("grey", "gray"):
|
||||
color = "gray"
|
||||
|
||||
Returns list of supplies with name and level percentage.
|
||||
if not color:
|
||||
name = (item.get("name") or "").lower()
|
||||
for candidate in ("cyan", "magenta", "yellow", "black"):
|
||||
if candidate in name:
|
||||
color = candidate
|
||||
break
|
||||
if not color and ("gray" in name or "grey" in name):
|
||||
color = "gray"
|
||||
return color
|
||||
|
||||
def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]:
|
||||
"""Current supply levels for a printer, by IP.
|
||||
|
||||
Returns a list of dicts {name, level, color, itemid, status, state},
|
||||
or None if the host is not in Zabbix. Drum/maintenance items are kept
|
||||
(callers decide what to surface); only disabled (status=1) and
|
||||
unsupported (state=1) items are dropped, matching the classic report.
|
||||
"""
|
||||
# Find host by IP
|
||||
host = self.gethostbyip(ip)
|
||||
if not host:
|
||||
logger.debug(f"No Zabbix host found for IP {ip}")
|
||||
hostid = self.gethostidbyip(ip)
|
||||
if not hostid:
|
||||
logger.debug("No Zabbix host for IP %s", ip)
|
||||
return None
|
||||
|
||||
hostid = host['hostid']
|
||||
|
||||
# Get supply-related items
|
||||
items = self._apicall('item.get', {
|
||||
'output': ['itemid', 'name', 'lastvalue', 'key_'],
|
||||
'hostids': hostid,
|
||||
'search': {
|
||||
'key_': 'supply' # Common key pattern for printer supplies
|
||||
},
|
||||
'searchWildcardsEnabled': True
|
||||
items = self._apicall("item.get", {
|
||||
"output": ["itemid", "name", "lastvalue", "lastclock",
|
||||
"units", "status", "state"],
|
||||
"hostids": hostid,
|
||||
"selectTags": "extend",
|
||||
"evaltype": 0, # and
|
||||
"tags": self.SUPPLY_TAGS,
|
||||
"sortfield": "name",
|
||||
"monitored": True,
|
||||
})
|
||||
|
||||
if not items:
|
||||
# Try alternate patterns
|
||||
items = self._apicall('item.get', {
|
||||
'output': ['itemid', 'name', 'lastvalue', 'key_'],
|
||||
'hostids': hostid,
|
||||
'search': {
|
||||
'name': 'toner'
|
||||
},
|
||||
'searchWildcardsEnabled': True
|
||||
})
|
||||
|
||||
if not items:
|
||||
return []
|
||||
|
||||
supplies = []
|
||||
for item in items:
|
||||
# skip disabled or unsupported items
|
||||
if str(item.get("status", "0")) != "0":
|
||||
continue
|
||||
if str(item.get("state", "0")) != "0":
|
||||
continue
|
||||
try:
|
||||
level = int(float(item.get('lastvalue', 0)))
|
||||
level = int(float(item.get("lastvalue", 0)))
|
||||
except (ValueError, TypeError):
|
||||
level = 0
|
||||
|
||||
supplies.append({
|
||||
'name': item.get('name', 'Unknown'),
|
||||
'level': level,
|
||||
'itemid': item.get('itemid'),
|
||||
'key': item.get('key_'),
|
||||
"name": item.get("name", "Unknown"),
|
||||
"level": level,
|
||||
"color": self._extract_color(item),
|
||||
"itemid": item.get("itemid"),
|
||||
})
|
||||
|
||||
return supplies
|
||||
|
||||
def gethostid(self, ip: str) -> Optional[str]:
|
||||
"""Get Zabbix host ID for an IP address."""
|
||||
host = self.gethostbyip(ip)
|
||||
return host['hostid'] if host else None
|
||||
def getpingstatus(self, ip: str) -> str:
|
||||
"""ICMP ping state for a printer: '1' up, '0' down, '-1' unknown."""
|
||||
hostid = self.gethostidbyip(ip)
|
||||
if not hostid:
|
||||
return "-1"
|
||||
items = self._apicall("item.get", {
|
||||
"output": ["lastvalue"],
|
||||
"hostids": hostid,
|
||||
"search": {"key_": "icmpping"},
|
||||
})
|
||||
if items:
|
||||
return str(items[0].get("lastvalue", "-1"))
|
||||
return "-1"
|
||||
|
||||
# -- caching wrappers ----------------------------------------------------
|
||||
|
||||
def getsuppliesbyip_cached(self, ip: str) -> Optional[List[Dict]]:
|
||||
"""Get printer supply levels with caching (10-minute TTL)."""
|
||||
cache_key = f'zabbix_supplies_{ip}'
|
||||
"""getsuppliesbyip with a 5-minute per-IP cache."""
|
||||
cache_key = f"zabbix_supplies_{ip}"
|
||||
result = cache.get(cache_key)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
result = self.getsuppliesbyip(ip)
|
||||
if result is not None:
|
||||
cache.set(cache_key, result, timeout=self.CACHE_TTL)
|
||||
return result
|
||||
|
||||
def clearcache(self, ip: str = None):
|
||||
"""Clear cached supply data for one IP or all."""
|
||||
"""Drop cached supply data for one IP, plus the low-supplies roll-up."""
|
||||
if ip:
|
||||
cache.delete(f'zabbix_supplies_{ip}')
|
||||
cache.delete('printers_low_supplies')
|
||||
cache.delete(f"zabbix_supplies_{ip}")
|
||||
cache.delete("printers_low_supplies")
|
||||
cache.delete("zabbix_reachable")
|
||||
|
||||
@@ -4,15 +4,7 @@ from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import AuditLog
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
from shopdb.api import db, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
|
||||
from ..models import USBDevice, USBDeviceType, USBCheckout
|
||||
|
||||
|
||||
@@ -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,167 +1,166 @@
|
||||
"""USB device plugin models."""
|
||||
|
||||
from datetime import datetime
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models.base import BaseModel, AuditMixin
|
||||
|
||||
|
||||
class USBDeviceType(BaseModel):
|
||||
"""
|
||||
USB device type classification.
|
||||
|
||||
Examples: Flash Drive, External HDD, External SSD, Card Reader
|
||||
"""
|
||||
__tablename__ = 'usbdevicetypes'
|
||||
|
||||
usbdevicetypeid = db.Column(db.Integer, primary_key=True)
|
||||
typename = db.Column(db.String(50), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), default='usb', comment='Icon name for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<USBDeviceType {self.typename}>"
|
||||
|
||||
|
||||
class USBDevice(BaseModel, AuditMixin):
|
||||
"""
|
||||
USB device model.
|
||||
|
||||
Tracks USB storage devices that can be checked out by users.
|
||||
"""
|
||||
__tablename__ = 'usbdevices'
|
||||
|
||||
usbdeviceid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Identification
|
||||
serialnumber = db.Column(db.String(100), unique=True, nullable=False)
|
||||
label = db.Column(db.String(100), nullable=True, comment='Human-readable label')
|
||||
assetnumber = db.Column(db.String(50), nullable=True, comment='Optional asset tag')
|
||||
|
||||
# Classification
|
||||
usbdevicetypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('usbdevicetypes.usbdevicetypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Specifications
|
||||
capacitygb = db.Column(db.Integer, nullable=True, comment='Capacity in GB')
|
||||
vendorid = db.Column(db.String(10), nullable=True, comment='USB Vendor ID (hex)')
|
||||
productid = db.Column(db.String(10), nullable=True, comment='USB Product ID (hex)')
|
||||
manufacturer = db.Column(db.String(100), nullable=True)
|
||||
productname = db.Column(db.String(100), nullable=True)
|
||||
|
||||
# Current status
|
||||
ischeckedout = db.Column(db.Boolean, default=False)
|
||||
currentuserid = db.Column(db.String(50), nullable=True, comment='SSO of current user')
|
||||
currentusername = db.Column(db.String(100), nullable=True, comment='Name of current user')
|
||||
currentcheckoutdate = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Location
|
||||
storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out')
|
||||
|
||||
# Security
|
||||
pin = db.Column(db.String(50), nullable=True, comment='PIN for encrypted devices')
|
||||
|
||||
# Notes
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
devicetype = db.relationship('USBDeviceType', backref='devices')
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
db.Index('idx_usb_serial', 'serialnumber'),
|
||||
db.Index('idx_usb_checkedout', 'ischeckedout'),
|
||||
db.Index('idx_usb_type', 'usbdevicetypeid'),
|
||||
db.Index('idx_usb_currentuser', 'currentuserid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<USBDevice {self.label or self.serialnumber}>"
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Get display name (label if set, otherwise serial number)."""
|
||||
return self.label or self.serialnumber
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related data."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add type info
|
||||
if self.devicetype:
|
||||
result['typename'] = self.devicetype.typename
|
||||
result['typeicon'] = self.devicetype.icon
|
||||
|
||||
# Add computed property
|
||||
result['displayname'] = self.display_name
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class USBCheckout(BaseModel):
|
||||
"""
|
||||
USB device checkout history.
|
||||
|
||||
Tracks when devices are checked out and returned.
|
||||
Maps to existing usbcheckouts table from classic ShopDB.
|
||||
"""
|
||||
__tablename__ = 'usbcheckouts'
|
||||
|
||||
checkoutid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Device reference (new column linking to usbdevices table)
|
||||
usbdeviceid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Legacy reference to machines table (kept for backward compatibility)
|
||||
machineid = db.Column(db.Integer, nullable=False)
|
||||
|
||||
# User info
|
||||
sso = db.Column(db.String(20), nullable=False, comment='SSO of user')
|
||||
checkoutname = db.Column(db.String(100), nullable=True, comment='Name of user')
|
||||
|
||||
# Checkout details
|
||||
checkouttime = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
checkintime = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Metadata
|
||||
checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout')
|
||||
checkinnotes = db.Column(db.Text, nullable=True)
|
||||
waswiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return')
|
||||
|
||||
# Relationships
|
||||
device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic'))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<USBCheckout device={self.usbdeviceid} user={self.sso}>"
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
"""Check if this checkout is currently active (not returned)."""
|
||||
return self.checkintime is None
|
||||
|
||||
@property
|
||||
def duration_days(self):
|
||||
"""Get duration of checkout in days."""
|
||||
end = self.checkintime or datetime.utcnow()
|
||||
delta = end - self.checkouttime
|
||||
return delta.days
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with computed fields."""
|
||||
result = super().to_dict()
|
||||
|
||||
result['isactivecheckout'] = self.is_active
|
||||
result['durationdays'] = self.duration_days
|
||||
|
||||
# Add device info if loaded
|
||||
if self.device:
|
||||
result['devicelabel'] = self.device.label
|
||||
result['deviceserialnumber'] = self.device.serialnumber
|
||||
|
||||
return result
|
||||
"""USB device plugin models."""
|
||||
|
||||
from datetime import datetime
|
||||
from shopdb.api import db, BaseModel, AuditMixin
|
||||
|
||||
|
||||
class USBDeviceType(BaseModel):
|
||||
"""
|
||||
USB device type classification.
|
||||
|
||||
Examples: Flash Drive, External HDD, External SSD, Card Reader
|
||||
"""
|
||||
__tablename__ = 'usbdevicetypes'
|
||||
|
||||
usbdevicetypeid = db.Column(db.Integer, primary_key=True)
|
||||
typename = db.Column(db.String(50), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), default='usb', comment='Icon name for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<USBDeviceType {self.typename}>"
|
||||
|
||||
|
||||
class USBDevice(BaseModel, AuditMixin):
|
||||
"""
|
||||
USB device model.
|
||||
|
||||
Tracks USB storage devices that can be checked out by users.
|
||||
"""
|
||||
__tablename__ = 'usbdevices'
|
||||
|
||||
usbdeviceid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Identification
|
||||
serialnumber = db.Column(db.String(100), unique=True, nullable=False)
|
||||
label = db.Column(db.String(100), nullable=True, comment='Human-readable label')
|
||||
assetnumber = db.Column(db.String(50), nullable=True, comment='Optional asset tag')
|
||||
|
||||
# Classification
|
||||
usbdevicetypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('usbdevicetypes.usbdevicetypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Specifications
|
||||
capacitygb = db.Column(db.Integer, nullable=True, comment='Capacity in GB')
|
||||
vendorid = db.Column(db.String(10), nullable=True, comment='USB Vendor ID (hex)')
|
||||
productid = db.Column(db.String(10), nullable=True, comment='USB Product ID (hex)')
|
||||
manufacturer = db.Column(db.String(100), nullable=True)
|
||||
productname = db.Column(db.String(100), nullable=True)
|
||||
|
||||
# Current status
|
||||
ischeckedout = db.Column(db.Boolean, default=False)
|
||||
currentuserid = db.Column(db.String(50), nullable=True, comment='SSO of current user')
|
||||
currentusername = db.Column(db.String(100), nullable=True, comment='Name of current user')
|
||||
currentcheckoutdate = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Location
|
||||
storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out')
|
||||
|
||||
# Security
|
||||
pin = db.Column(db.String(50), nullable=True, comment='PIN for encrypted devices')
|
||||
|
||||
# Notes
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
devicetype = db.relationship('USBDeviceType', backref='devices')
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
db.Index('idx_usb_serial', 'serialnumber'),
|
||||
db.Index('idx_usb_checkedout', 'ischeckedout'),
|
||||
db.Index('idx_usb_type', 'usbdevicetypeid'),
|
||||
db.Index('idx_usb_currentuser', 'currentuserid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<USBDevice {self.label or self.serialnumber}>"
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Get display name (label if set, otherwise serial number)."""
|
||||
return self.label or self.serialnumber
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related data."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add type info
|
||||
if self.devicetype:
|
||||
result['typename'] = self.devicetype.typename
|
||||
result['typeicon'] = self.devicetype.icon
|
||||
|
||||
# Add computed property
|
||||
result['displayname'] = self.display_name
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class USBCheckout(BaseModel):
|
||||
"""
|
||||
USB device checkout history.
|
||||
|
||||
Tracks when devices are checked out and returned.
|
||||
Maps to existing usbcheckouts table from classic ShopDB.
|
||||
"""
|
||||
__tablename__ = 'usbcheckouts'
|
||||
|
||||
checkoutid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Device reference (new column linking to usbdevices table)
|
||||
usbdeviceid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Legacy reference to machines table (kept for backward compatibility)
|
||||
machineid = db.Column(db.Integer, nullable=False)
|
||||
|
||||
# User info
|
||||
sso = db.Column(db.String(20), nullable=False, comment='SSO of user')
|
||||
checkoutname = db.Column(db.String(100), nullable=True, comment='Name of user')
|
||||
|
||||
# Checkout details
|
||||
checkouttime = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
checkintime = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Metadata
|
||||
checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout')
|
||||
checkinnotes = db.Column(db.Text, nullable=True)
|
||||
waswiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return')
|
||||
|
||||
# Relationships
|
||||
device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic'))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<USBCheckout device={self.usbdeviceid} user={self.sso}>"
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
"""Check if this checkout is currently active (not returned)."""
|
||||
return self.checkintime is None
|
||||
|
||||
@property
|
||||
def duration_days(self):
|
||||
"""Get duration of checkout in days."""
|
||||
end = self.checkintime or datetime.utcnow()
|
||||
delta = end - self.checkouttime
|
||||
return delta.days
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with computed fields."""
|
||||
result = super().to_dict()
|
||||
|
||||
result['isactivecheckout'] = self.is_active
|
||||
result['durationdays'] = self.duration_days
|
||||
|
||||
# Add device info if loaded
|
||||
if self.device:
|
||||
result['devicelabel'] = self.device.label
|
||||
result['deviceserialnumber'] = self.device.serialnumber
|
||||
|
||||
return result
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import List, Dict, Optional, Type
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.extensions import db
|
||||
from shopdb.api import db
|
||||
|
||||
from .models import USBDevice, USBDeviceType, USBCheckout
|
||||
from .api import usb_bp
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user